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
10 XMLNS_RELEASE
= 'http://zero-install.sourceforge.net/2007/namespaces/0release'
12 release_status_file
= 'release-status'
14 valid_phases
= ['commit-release', 'generate-archive']
16 def run_unit_tests(impl
):
17 self_test
= impl
.metadata
.get('self-test', None)
19 print "SKIPPING unit tests for %s (no 'self-test' attribute set)" % impl
21 self_test
= os
.path
.join(impl
.id, self_test
)
22 print "Running self-test:", self_test
23 exitstatus
= subprocess
.call([self_test
], cwd
= os
.path
.dirname(self_test
))
25 raise SafeException("Self-test failed with exit status %d" % exitstatus
)
27 def show_and_run(cmd
, args
):
28 print "Executing: %s %s" % (cmd
, ' '.join("[%s]" % x
for x
in args
))
29 subprocess
.check_call(['sh', '-c', cmd
, '-'] + args
)
31 def suggest_release_version(snapshot_version
):
32 """Given a snapshot version, suggest a suitable release version.
33 >>> suggest_release_version('1.0-pre')
35 >>> suggest_release_version('0.9-post')
37 >>> suggest_release_version('3')
38 Traceback (most recent call last):
40 SafeException: Version '3' is not a snapshot version (should end in -pre or -post)
42 version
= model
.parse_version(snapshot_version
)
45 raise SafeException("Version '%s' is not a snapshot version (should end in -pre or -post)" % snapshot_version
)
47 # -post, so increment the number
49 version
[-1] = 0 # Remove the modifier
50 return model
.format_version(version
)
52 def publish(iface
, **kwargs
):
53 args
= [os
.environ
['0PUBLISH']]
57 args
+= ['--' + k
.replace('_', '-')]
58 elif value
is not None:
59 args
+= ['--' + k
.replace('_', '-'), value
]
61 info("Executing %s", args
)
62 subprocess
.check_call(args
)
64 def get_singleton_impl(iface
):
65 impls
= iface
.implementations
67 raise SafeException("Local feed '%s' contains %d versions! I need exactly one!" % (iface
.uri
, len(impls
)))
68 return impls
.values()[0]
70 def backup_if_exists(name
):
71 if not os
.path
.exists(name
):
74 if os
.path
.exists(backup
):
75 print "(deleting old backup %s)" % backup
76 if os
.path
.isdir(backup
):
80 os
.rename(name
, backup
)
81 print "(renamed old %s as %s; will delete on next run)" % (name
, backup
)
84 __slots__
= ['old_snapshot_version', 'release_version', 'head_before_release', 'new_snapshot_version', 'head_at_release', 'created_archive', 'tagged']
86 for name
in self
.__slots
__:
87 setattr(self
, name
, None)
89 if os
.path
.isfile(release_status_file
):
90 for line
in file(release_status_file
):
91 assert line
.endswith('\n')
93 name
, value
= line
.split('=')
94 setattr(self
, name
, value
)
95 info("Loaded status %s=%s", name
, value
)
98 tmp_name
= release_status_file
+ '.new'
99 tmp
= file(tmp_name
, 'w')
101 lines
= ["%s=%s\n" % (name
, getattr(self
, name
)) for name
in self
.__slots
__ if getattr(self
, name
)]
102 tmp
.write(''.join(lines
))
104 os
.rename(tmp_name
, release_status_file
)
105 info("Wrote status to %s", release_status_file
)
110 def get_choice(*options
):
112 choice
= raw_input('/'.join(options
) + ': ').lower()
113 if not choice
: continue
115 if o
.lower().startswith(choice
):
118 def do_release(local_iface
, options
):
120 local_impl
= get_singleton_impl(local_iface
)
122 local_impl_dir
= local_impl
.id
123 assert local_impl_dir
.startswith('/')
124 local_impl_dir
= os
.path
.realpath(local_impl_dir
)
125 assert os
.path
.isdir(local_impl_dir
)
126 assert local_iface
.uri
.startswith(local_impl_dir
+ '/')
127 local_iface_rel_path
= local_iface
.uri
[len(local_impl_dir
) + 1:]
128 assert not local_iface_rel_path
.startswith('/')
129 assert os
.path
.isfile(os
.path
.join(local_impl_dir
, local_iface_rel_path
))
132 for phase
in valid_phases
:
133 phase_actions
[phase
] = [] # List of <release:action> elements
135 release_management
= local_iface
.get_metadata(XMLNS_RELEASE
, 'management')
136 if len(release_management
) == 1:
137 info("Found <release:management> element.")
138 release_management
= release_management
[0]
139 for x
in release_management
.childNodes
:
140 if x
.uri
== XMLNS_RELEASE
and x
.name
== 'action':
141 phase
= x
.getAttribute('phase')
142 if phase
not in valid_phases
:
143 raise SafeException("Invalid action phase '%s' in local feed %s. Valid actions are:\n%s" % (phase
, local_iface
.uri
, '\n'.join(valid_phases
)))
144 phase_actions
[phase
].append(x
.content
)
146 warn("Unknown <release:management> element: %s", x
)
147 elif len(release_management
) > 1:
148 raise SafeException("Multiple <release:management> sections in %s!" % local_iface
)
150 info("No <release:management> element found in local feed.")
152 scm
= GIT(local_iface
, options
)
154 def run_hooks(phase
, cwd
, env
):
155 info("Running hooks for phase '%s'" % phase
)
156 full_env
= os
.environ
.copy()
158 for x
in phase_actions
[phase
]:
159 print "[%s]: %s" % (phase
, x
)
160 subprocess
.check_call(x
, shell
= True, cwd
= cwd
, env
= full_env
)
162 def set_to_release():
163 print "Snapshot version is " + local_impl
.get_version()
164 suggested
= suggest_release_version(local_impl
.get_version())
165 release_version
= raw_input("Version number for new release [%s]: " % suggested
)
166 if not release_version
:
167 release_version
= suggested
169 scm
.ensure_no_tag(release_version
)
171 status
.head_before_release
= scm
.get_head_revision()
174 working_copy
= local_impl
.id
175 run_hooks('commit-release', cwd
= working_copy
, env
= {'RELEASE_VERSION': release_version
})
177 print "Releasing version", release_version
178 publish(local_iface
.uri
, set_released
= 'today', set_version
= release_version
)
180 status
.old_snapshot_version
= local_impl
.get_version()
181 status
.release_version
= release_version
182 scm
.commit('Release %s' % release_version
)
183 status
.head_at_release
= scm
.get_head_revision()
186 return release_version
188 def set_to_snapshot(snapshot_version
):
189 assert snapshot_version
.endswith('-post')
190 publish(local_iface
.uri
, set_released
= '', set_version
= snapshot_version
)
191 scm
.commit('Start development series %s' % snapshot_version
)
192 status
.new_snapshot_version
= scm
.get_head_revision()
195 def ensure_ready_to_release():
196 scm
.ensure_committed()
197 info("No uncommitted changes. Good.")
198 # Not needed for GIT. For SCMs where tagging is expensive (e.g. svn) this might be useful.
199 #run_unit_tests(local_impl)
201 def create_feed(local_iface_stream
, archive_file
, archive_name
, version
):
202 tmp
= tempfile
.NamedTemporaryFile(prefix
= '0release-')
203 shutil
.copyfileobj(local_iface_stream
, tmp
)
207 archive_url
= options
.archive_dir_public_url
+ '/' + os
.path
.basename(archive_file
),
208 archive_file
= archive_file
,
209 archive_extract
= archive_name
)
212 def unpack_tarball(archive_file
, archive_name
):
213 tar
= tarfile
.open(archive_file
, 'r:bz2')
214 members
= [m
for m
in tar
.getmembers() if m
.name
!= 'pax_global_header']
215 tar
.extractall('.', members
= members
)
217 def export_changelog():
218 parsed_release_version
= model
.parse_version(status
.release_version
)
220 previous_release
= None
221 if os
.path
.exists(options
.master_feed_file
):
222 master
= model
.Interface(os
.path
.realpath(options
.master_feed_file
))
223 reader
.update(master
, master
.uri
, local
= True)
224 versions
= [impl
.version
for impl
in master
.implementations
.values() if impl
.version
< parsed_release_version
]
226 previous_release
= model
.format_version(max(versions
))
228 changelog
= file('changelog-%s' % status
.release_version
, 'w')
231 scm
.export_changelog(previous_release
, status
.head_before_release
, changelog
)
232 except SafeException
, ex
:
233 print "WARNING: Failed to generate changelog: " + str(ex
)
235 print "Wrote changelog from %s to here as %s" % (previous_release
or 'start', changelog
.name
)
239 def fail_candidate(archive_file
):
240 backup_if_exists(archive_file
)
241 head
= scm
.get_head_revision()
242 if head
!= status
.new_snapshot_version
:
243 raise SafeException("There have been commits since starting the release! Please rebase them onto %s" % status
.head_before_release
)
244 # Check no uncommitted changes
245 scm
.ensure_committed()
246 scm
.reset_hard(status
.head_before_release
)
247 os
.unlink(release_status_file
)
248 print "Restored to state before starting release. Make your fixes and try again..."
250 def accept_and_publish(archive_file
, archive_name
, local_iface_rel_path
):
251 assert options
.master_feed_file
254 print "Already tagged and added to master feed."
256 tar
= tarfile
.open(archive_file
, 'r:bz2')
257 stream
= tar
.extractfile(tar
.getmember(archive_name
+ '/' + local_iface_rel_path
))
258 remote_dl_iface
= create_feed(stream
, archive_file
, archive_name
, version
)
261 publish(options
.master_feed_file
, local
= remote_dl_iface
.name
, xmlsign
= True, key
= options
.key
)
262 remote_dl_iface
.close()
264 scm
.tag(status
.release_version
, status
.head_at_release
)
266 status
.tagged
= 'true'
270 print "Upload %s as %s" % (archive_file
, options
.archive_dir_public_url
+ '/' + os
.path
.basename(archive_file
))
271 cmd
= options
.archive_upload_command
.strip()
273 show_and_run(cmd
, [archive_file
])
275 print "NOTE: No upload command set => you'll have to upload it yourself!"
277 assert len(local_iface
.feed_for
) == 1
278 feed_base
= os
.path
.dirname(local_iface
.feed_for
.keys()[0])
279 feed_files
= [options
.master_feed_file
]
280 print "Upload %s into %s" % (', '.join(feed_files
), feed_base
)
281 cmd
= options
.master_feed_upload_command
.strip()
283 show_and_run(cmd
, feed_files
)
285 print "NOTE: No feed upload command set => you'll have to upload them yourself!"
287 print "Push changes to public SCM repository..."
288 public_repos
= options
.public_scm_repository
290 scm
.push_head_and_release(status
.release_version
)
292 print "NOTE: No public repository set => you'll have to push the tag and trunk yourself."
294 os
.unlink(release_status_file
)
296 if status
.head_before_release
:
297 head
= scm
.get_head_revision()
298 if status
.release_version
:
299 print "RESUMING release of version %s" % status
.release_version
300 elif head
== status
.head_before_release
:
301 print "Restarting release (HEAD revision has not changed)"
303 raise SafeException("Something went wrong with the last run:\n" +
304 "HEAD revision for last run was " + status
.head_before_release
+ "\n" +
305 "HEAD revision now is " + head
+ "\n" +
306 "You should revert your working copy to the previous head and try again.\n" +
307 "If you're sure you want to release from the current head, delete '" + release_status_file
+ "'")
309 print "Releasing", local_iface
.get_name()
311 ensure_ready_to_release()
313 if status
.release_version
:
314 version
= status
.release_version
315 need_set_snapshot
= False
316 if status
.new_snapshot_version
:
317 head
= scm
.get_head_revision()
318 if head
!= status
.new_snapshot_version
:
319 print "WARNING: there are more commits since we tagged; they will not be included in the release!"
321 raise SafeException("Something went wrong previously when setting the new snapshot version.\n" +
322 "Suggest you reset to the original HEAD of\n%s and delete '%s'." % (status
.head_before_release
, release_status_file
))
324 version
= set_to_release()
325 need_set_snapshot
= True
327 archive_name
= local_iface
.get_name().lower().replace(' ', '-') + '-' + version
328 archive_file
= archive_name
+ '.tar.bz2'
330 if status
.created_archive
and os
.path
.isfile(archive_file
):
331 print "Archive already created"
333 backup_if_exists(archive_file
)
334 scm
.export(archive_name
, archive_file
)
336 if phase_actions
['generate-archive']:
338 unpack_tarball(archive_file
, archive_name
)
339 run_hooks('generate-archive', cwd
= archive_name
, env
= {'RELEASE_VERSION': status
.release_version
})
340 info("Regenerating archive (may have been modified by generate-archive hooks...")
341 subprocess
.check_call(['tar', 'cjf', archive_file
, archive_name
])
342 except SafeException
:
343 fail_candidate(archive_file
)
346 status
.created_archive
= 'true'
349 if need_set_snapshot
:
350 set_to_snapshot(version
+ '-post')
352 #backup_if_exists(archive_name)
353 unpack_tarball(archive_file
, archive_name
)
355 main
= os
.path
.join(archive_name
, local_impl
.main
)
356 if not os
.path
.exists(main
):
357 raise SafeException("Main executable '%s' not found after unpacking archive!" % main
)
359 extracted_iface_path
= os
.path
.abspath(os
.path
.join(archive_name
, local_iface_rel_path
))
360 extracted_iface
= model
.Interface(extracted_iface_path
)
361 reader
.update(extracted_iface
, extracted_iface_path
, local
= True)
362 extracted_impl
= get_singleton_impl(extracted_iface
)
365 run_unit_tests(extracted_impl
)
366 except SafeException
:
367 print "(leaving extracted directory for examination)"
368 fail_candidate(archive_file
)
370 # Unpack it again in case the unit-tests changed anything
371 shutil
.rmtree(archive_name
)
372 unpack_tarball(archive_file
, archive_name
)
376 print "\nCandidate release archive:", archive_file
377 print "(extracted to %s for inspection)" % os
.path
.abspath(archive_name
)
379 print "\nPlease check candidate and select an action:"
380 print "P) Publish candidate (accept)"
381 print "F) Fail candidate (untag)"
382 print "(you can also hit CTRL-C and resume this script when done)"
383 choice
= get_choice('Publish', 'Fail')
385 info("Deleting extracted archive %s", archive_name
)
386 shutil
.rmtree(archive_name
)
388 if choice
== 'Publish':
389 accept_and_publish(archive_file
, archive_name
, local_iface_rel_path
)
391 assert choice
== 'Fail'
392 fail_candidate(archive_file
)
395 if __name__
== "__main__":