|
1 #!/usr/bin/python2.5 |
|
2 # |
|
3 # Copyright 2009 the Melange authors. |
|
4 # |
|
5 # Licensed under the Apache License, Version 2.0 (the "License"); |
|
6 # you may not use this file except in compliance with the License. |
|
7 # You may obtain a copy of the License at |
|
8 # |
|
9 # http://www.apache.org/licenses/LICENSE-2.0 |
|
10 # |
|
11 # Unless required by applicable law or agreed to in writing, software |
|
12 # distributed under the License is distributed on an "AS IS" BASIS, |
|
13 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
|
14 # See the License for the specific language governing permissions and |
|
15 # limitations under the License. |
|
16 |
|
17 from __future__ import with_statement |
|
18 |
|
19 """Google Summer of Code Melange release script. |
|
20 |
|
21 This script provides automation for the various tasks involved in |
|
22 pushing a new release of Melange to the official Google Summer of Code |
|
23 app engine instance. |
|
24 |
|
25 It does not provide a turnkey autopilot solution. Notably, each stage |
|
26 of the release process must be started by a human operator, and some |
|
27 commands will request confirmation or extra details before |
|
28 proceeding. It is not a replacement for a cautious human |
|
29 operator. |
|
30 |
|
31 Note that this script requires: |
|
32 - Python 2.5 or better (for various language features) |
|
33 |
|
34 - Subversion 1.5.0 or better (for working copy depth control, which |
|
35 cuts down checkout/update times by several orders of |
|
36 magnitude). |
|
37 """ |
|
38 |
|
39 __authors__ = [ |
|
40 # alphabetical order by last name, please |
|
41 '"David Anderson" <dave@natulte.net>', |
|
42 ] |
|
43 |
|
44 import functools |
|
45 import os |
|
46 import re |
|
47 import subprocess |
|
48 import sys |
|
49 |
|
50 |
|
51 # Default repository URLs for Melange and the Google release |
|
52 # repository. |
|
53 MELANGE_REPOS = 'http://soc.googlecode.com/svn' |
|
54 GOOGLE_SOC_REPOS = 'https://soc-google.googlecode.com/svn' |
|
55 |
|
56 |
|
57 # Regular expression matching an apparently well formed Melange |
|
58 # release number. |
|
59 MELANGE_RELEASE_RE = re.compile(r'\d-\d-\d{8}') |
|
60 |
|
61 |
|
62 class Error(Exception): |
|
63 pass |
|
64 |
|
65 |
|
66 class SubprocessFailed(Error): |
|
67 """A subprocess returned a non-zero error code.""" |
|
68 |
|
69 |
|
70 class AbortedByUser(Error): |
|
71 """The operation was aborted by the user.""" |
|
72 |
|
73 |
|
74 class ObstructionError(Error): |
|
75 """An operation was obstructed by existing data.""" |
|
76 |
|
77 |
|
78 class ExpectationFailed(Error): |
|
79 """An unexpected state was encountered by an automated step.""" |
|
80 |
|
81 |
|
82 class FileAccessError(Error): |
|
83 """An error occured while accessing a file.""" |
|
84 |
|
85 |
|
86 def run(argv, cwd=None, capture=False, split_capture=True, stdin=''): |
|
87 """Run the given command and optionally return its output. |
|
88 |
|
89 Note that if you set capture=True, the command's output is |
|
90 buffered in memory. Output capture should only be used with |
|
91 commands that output small amounts of data. O(kB) is fine, O(MB) |
|
92 is starting to push it a little. |
|
93 |
|
94 Args: |
|
95 argv: A list containing the name of the program to run, followed |
|
96 by its argument vector. |
|
97 cwd: Run the program from this directory. |
|
98 capture: If True, capture the program's stdout stream. If False, |
|
99 stdout will output to sys.stdout. |
|
100 split_capture: If True, return the captured output as a list of |
|
101 lines. Else, return as a single unaltered string. |
|
102 stdin: The string to feed to the program's stdin stream. |
|
103 |
|
104 Returns: |
|
105 If capture is True, a string containing the combined |
|
106 stdout/stderr output of the program. If capture is False, |
|
107 nothing is returned. |
|
108 |
|
109 Raises: |
|
110 SubprocessFailed: The subprocess exited with a non-zero exit |
|
111 code. |
|
112 """ |
|
113 print '\x1b[1m# %s\x1b[22m' % ' '.join(argv) |
|
114 |
|
115 process = subprocess.Popen(argv, |
|
116 shell=False, |
|
117 cwd=cwd, |
|
118 stdin=subprocess.PIPE, |
|
119 stdout=(subprocess.PIPE if capture else None), |
|
120 stderr=None) |
|
121 output, _ = process.communicate(input=stdin) |
|
122 if process.returncode != 0: |
|
123 raise SubprocessFailed('Process %s failed with output: %s' % |
|
124 (argv[0], output)) |
|
125 if output is not None and split_capture: |
|
126 return output.strip().split('\n') |
|
127 else: |
|
128 return output |
|
129 |
|
130 |
|
131 def error(msg): |
|
132 """Print an error message, with appropriate formatting.""" |
|
133 print '\x1b[1m\x1b[31m%s\x1b[0m' % msg |
|
134 |
|
135 |
|
136 def info(msg): |
|
137 """Print an informational message, with appropriate formatting.""" |
|
138 print '\x1b[32m%s\x1b[0m' % msg |
|
139 |
|
140 |
|
141 def confirm(prompt, default=False): |
|
142 """Ask a yes/no question and return the answer. |
|
143 |
|
144 Will reprompt the user until one of "yes", "no", "y" or "n" is |
|
145 entered. The input is case insensitive. |
|
146 |
|
147 Args: |
|
148 prompt: The question to ask the user. |
|
149 default: The answer to return if the user just hits enter. |
|
150 |
|
151 Returns: |
|
152 True if the user answered affirmatively, False otherwise. |
|
153 """ |
|
154 if default: |
|
155 question = prompt + ' [Yn] ' |
|
156 else: |
|
157 question = prompt + ' [yN] ' |
|
158 while True: |
|
159 try: |
|
160 answer = raw_input(question).strip().lower() |
|
161 except EOFError: |
|
162 raise AbortedByUser('Aborted by ctrl+D') |
|
163 if not answer: |
|
164 return default |
|
165 elif answer in ('y', 'yes'): |
|
166 return True |
|
167 elif answer in ('n', 'no'): |
|
168 return False |
|
169 else: |
|
170 error('Please answer yes or no.') |
|
171 |
|
172 |
|
173 def getString(prompt): |
|
174 """Prompt for and return a string.""" |
|
175 try: |
|
176 return raw_input(prompt + ' ').strip() |
|
177 except EOFError: |
|
178 raise AbortedByUser('Aborted by ctrl+D') |
|
179 |
|
180 |
|
181 def getNumber(prompt): |
|
182 """Prompt for and return a number. |
|
183 |
|
184 Will reprompt the user until a number is entered. |
|
185 """ |
|
186 while True: |
|
187 value_str = getString(prompt) |
|
188 try: |
|
189 return int(value_str) |
|
190 except ValueError: |
|
191 error('Please enter a number. You entered "%s".' % value_str) |
|
192 |
|
193 |
|
194 def getChoice(intro, prompt, choices, done=None, suggest=None): |
|
195 """Prompt for and return a choice from a menu. |
|
196 |
|
197 Will reprompt the user until a valid menu entry is chosen. |
|
198 |
|
199 Args: |
|
200 intro: Text to print verbatim before the choice menu. |
|
201 prompt: The prompt to print right before accepting input. |
|
202 choices: The list of string choices to display. |
|
203 done: If not None, the list of indices of previously |
|
204 selected/completed choices. |
|
205 suggest: If not None, the index of the choice to highlight as |
|
206 the suggested choice. |
|
207 |
|
208 Returns: |
|
209 The index in the choices list of the selection the user made. |
|
210 """ |
|
211 done = set(done or []) |
|
212 while True: |
|
213 print intro |
|
214 print |
|
215 for i, entry in enumerate(choices): |
|
216 done_text = ' (done)' if i in done else '' |
|
217 indent = '--> ' if i == suggest else ' ' |
|
218 print '%s%2d. %s%s' % (indent, i+1, entry, done_text) |
|
219 print |
|
220 choice = getNumber(prompt) |
|
221 if 0 < choice <= len(choices): |
|
222 return choice-1 |
|
223 error('%d is not a valid choice between %d and %d' % |
|
224 (choice, 1, len(choices))) |
|
225 print |
|
226 |
|
227 |
|
228 def fileToLines(path): |
|
229 """Read a file and return it as a list of lines.""" |
|
230 try: |
|
231 with file(path) as f: |
|
232 return f.read().split('\n') |
|
233 except (IOError, OSError), e: |
|
234 raise FileAccessError(str(e)) |
|
235 |
|
236 |
|
237 def linesToFile(path, lines): |
|
238 """Write a list of lines to a file.""" |
|
239 try: |
|
240 with file(path, 'w') as f: |
|
241 f.write('\n'.join(lines)) |
|
242 except (IOError, OSError), e: |
|
243 raise FileAccessError(str(e)) |
|
244 |
|
245 |
|
246 class Paths(object): |
|
247 """A helper to construct and check paths under a given root.""" |
|
248 |
|
249 def __init__(self, root): |
|
250 """Initializer. |
|
251 |
|
252 Args: |
|
253 root: The root of all paths this instance will consider. |
|
254 """ |
|
255 self._root = os.path.abspath( |
|
256 os.path.expandvars(os.path.expanduser(root))) |
|
257 |
|
258 def path(self, path=''): |
|
259 """Construct and return a path under the path root. |
|
260 |
|
261 Args: |
|
262 path: The desired path string relative to the root. |
|
263 |
|
264 Returns: |
|
265 The absolute path corresponding to the relative input path. |
|
266 """ |
|
267 assert not os.path.isabs(path) |
|
268 return os.path.abspath(os.path.join(self._root, path)) |
|
269 |
|
270 def exists(self, path=''): |
|
271 """Check for the existence of a path under the path root. |
|
272 |
|
273 Does not discriminate on the path type (ie. it could be a |
|
274 directory, a file, a symbolic link...), just checks for the |
|
275 existence of the path. |
|
276 |
|
277 Args: |
|
278 path: The path string relative to the root. |
|
279 |
|
280 Returns: |
|
281 True if the path exists, False otherwise. |
|
282 """ |
|
283 return os.path.exists(self.path(path)) |
|
284 |
|
285 |
|
286 class Subversion(Paths): |
|
287 """Wrapper for operations on a Subversion working copy. |
|
288 |
|
289 An instance of this class is bound to a specific working copy |
|
290 directory, and provides an API to perform various Subversion |
|
291 operations on this working copy. |
|
292 |
|
293 Some methods take a 'depth' argument. Depth in Subversion is a |
|
294 feature that allows the creation of arbitrarily shallow or deep |
|
295 working copies on a per-directory basis. Possible values are |
|
296 'none' (no files or directories), 'files' (only files in .), |
|
297 'immediates' (files and directories in ., directories checked out |
|
298 at depth 'none') or 'infinity' (a normal working copy with |
|
299 everything). |
|
300 |
|
301 This class also provides a few static functions that run the 'svn' |
|
302 tool against remote repositories to gather information or retrieve |
|
303 data. |
|
304 |
|
305 Note that this wrapper also doubles as a Paths object, offering an |
|
306 easy way to get or check the existence of paths in the working |
|
307 copy. |
|
308 """ |
|
309 |
|
310 def __init__(self, wc_dir): |
|
311 Paths.__init__(self, wc_dir) |
|
312 |
|
313 def _unknownAndMissing(self, path): |
|
314 """Returns lists of unknown and missing files in the working copy. |
|
315 |
|
316 Args: |
|
317 path: The working copy path to scan. |
|
318 |
|
319 Returns: |
|
320 |
|
321 Two lists. The first is a list of all unknown paths |
|
322 (subversion has no knowledge of them), the second is a list |
|
323 of missing paths (subversion knows about them, but can't |
|
324 find them). Paths in either list are relative to the input |
|
325 path. |
|
326 """ |
|
327 assert self.exists() |
|
328 unknown = [] |
|
329 missing = [] |
|
330 for line in self.status(path): |
|
331 if not line.strip(): |
|
332 continue |
|
333 if line[0] == '?': |
|
334 unknown.append(line[7:]) |
|
335 elif line[0] == '!': |
|
336 missing.append(line[7:]) |
|
337 return unknown, missing |
|
338 |
|
339 def checkout(self, url, depth='infinity'): |
|
340 """Check out a working copy from the given URL. |
|
341 |
|
342 Args: |
|
343 url: The Subversion repository URL to check out. |
|
344 depth: The depth of the working copy root. |
|
345 """ |
|
346 assert not self.exists() |
|
347 run(['svn', 'checkout', '--depth=' + depth, url, self.path()]) |
|
348 |
|
349 def update(self, path='', depth=None): |
|
350 """Update a working copy path, optionally changing depth. |
|
351 |
|
352 Args: |
|
353 path: The working copy path to update. |
|
354 depth: If set, change the depth of the path before updating. |
|
355 """ |
|
356 assert self.exists() |
|
357 if depth is None: |
|
358 run(['svn', 'update', self.path(path)]) |
|
359 else: |
|
360 run(['svn', 'update', '--set-depth=' + depth, self.path(path)]) |
|
361 |
|
362 def revert(self, path=''): |
|
363 """Recursively revert a working copy path. |
|
364 |
|
365 Note that this command is more zealous than the 'svn revert' |
|
366 command, as it will also delete any files which subversion |
|
367 does not know about. |
|
368 """ |
|
369 run(['svn', 'revert', '-R', self.path(path)]) |
|
370 |
|
371 unknown, missing = self._unknownAndMissing(path) |
|
372 unknown = [os.path.join(self.path(path), p) for p in unknown] |
|
373 |
|
374 if unknown: |
|
375 # rm -rf makes me uneasy. Verify that all paths to be deleted |
|
376 # are within the release working copy. |
|
377 for p in unknown: |
|
378 assert p.startswith(self.path()) |
|
379 |
|
380 run(['rm', '-rf', '--'] + unknown) |
|
381 |
|
382 def ls(self, dir=''): |
|
383 """List the contents of a working copy directory. |
|
384 |
|
385 Note that this returns the contents of the directory as seen |
|
386 by the server, not constrained by the depth settings of the |
|
387 local path. |
|
388 """ |
|
389 assert self.exists() |
|
390 return run(['svn', 'ls', self.path(dir)], capture=True) |
|
391 |
|
392 def copy(self, src, dest): |
|
393 """Copy a working copy path. |
|
394 |
|
395 The copy is only scheduled for commit, not committed. |
|
396 |
|
397 Args: |
|
398 src: The source working copy path. |
|
399 dst: The destination working copy path. |
|
400 """ |
|
401 assert self.exists() |
|
402 run(['svn', 'cp', self.path(src), self.path(dest)]) |
|
403 |
|
404 def propget(self, prop_name, path): |
|
405 """Get the value of a property on a working copy path. |
|
406 |
|
407 Args: |
|
408 prop_name: The property name, eg. 'svn:externals'. |
|
409 path: The working copy path on which the property is set. |
|
410 """ |
|
411 assert self.exists() |
|
412 return run(['svn', 'propget', prop_name, self.path(path)], capture=True) |
|
413 |
|
414 def propset(self, prop_name, prop_value, path): |
|
415 """Set the value of a property on a working copy path. |
|
416 |
|
417 The property change is only scheduled for commit, not committed. |
|
418 |
|
419 Args: |
|
420 prop_name: The property name, eg. 'svn:externals'. |
|
421 prop_value: The value that should be set. |
|
422 path: The working copy path on which to set the property. |
|
423 """ |
|
424 assert self.exists() |
|
425 run(['svn', 'propset', prop_name, prop_value, self.path(path)]) |
|
426 |
|
427 def add(self, paths): |
|
428 """Schedule working copy paths for addition. |
|
429 |
|
430 The paths are only scheduled for addition, not committed. |
|
431 |
|
432 Args: |
|
433 paths: The list of working copy paths to add. |
|
434 """ |
|
435 assert self.exists() |
|
436 paths = [self.path(p) for p in paths] |
|
437 run(['svn', 'add'] + paths) |
|
438 |
|
439 def remove(self, paths): |
|
440 """Schedule working copy paths for deletion. |
|
441 |
|
442 The paths are only scheduled for deletion, not committed. |
|
443 |
|
444 Args: |
|
445 paths: The list of working copy paths to delete. |
|
446 """ |
|
447 assert self.exists() |
|
448 paths = [self.path(p) for p in paths] |
|
449 run(['svn', 'rm'] + paths) |
|
450 |
|
451 def status(self, path=''): |
|
452 """Return the status of a working copy path. |
|
453 |
|
454 The status returned is the verbatim output of 'svn status' on |
|
455 the path. |
|
456 |
|
457 Args: |
|
458 path: The path to examine. |
|
459 """ |
|
460 assert self.exists() |
|
461 return run(['svn', 'status', self.path(path)], capture=True) |
|
462 |
|
463 def addRemove(self, path=''): |
|
464 """Perform an "addremove" operation a working copy path. |
|
465 |
|
466 An "addremove" runs 'svn status' and schedules all the unknown |
|
467 paths (listed as '?') for addition, and all the missing paths |
|
468 (listed as '!') for deletion. Its main use is to synchronize |
|
469 working copy state after applying a patch in unified diff |
|
470 format. |
|
471 |
|
472 Args: |
|
473 path: The path under which unknown/missing files should be |
|
474 added/removed. |
|
475 """ |
|
476 assert self.exists() |
|
477 unknown, missing = self._unknownAndMissing(path) |
|
478 if unknown: |
|
479 self.add(unknown) |
|
480 if missing: |
|
481 self.remove(missing) |
|
482 |
|
483 def commit(self, message, path=''): |
|
484 """Commit scheduled changes to the source repository. |
|
485 |
|
486 Args: |
|
487 message: The commit message to use. |
|
488 path: The path to commit. |
|
489 """ |
|
490 assert self.exists() |
|
491 run(['svn', 'commit', '-m', message, self.path(path)]) |
|
492 |
|
493 @staticmethod |
|
494 def export(url, revision, dest_path): |
|
495 """Export the contents of a repository to a local path. |
|
496 |
|
497 Note that while the underlying 'svn export' only requires a |
|
498 URL, we require that both a URL and a revision be specified, |
|
499 to fully qualify the data to export. |
|
500 |
|
501 Args: |
|
502 url: The repository URL to export. |
|
503 revision: The revision to export. |
|
504 dest_path: The destination directory for the export. Note |
|
505 that this is an absolute path, NOT a working copy |
|
506 relative path. |
|
507 """ |
|
508 assert os.path.isabs(dest_path) |
|
509 if os.path.exists(dest_path): |
|
510 raise ObstructionError('Cannot export to obstructed path %s' % |
|
511 dest_path) |
|
512 run(['svn', 'export', '-r', str(revision), url, dest_path]) |
|
513 |
|
514 @staticmethod |
|
515 def find_tag_rev(url): |
|
516 """Return the revision at which a remote tag was created. |
|
517 |
|
518 Since tags are immutable by convention, usually the HEAD of a |
|
519 tag should be the tag creation revision. However, mistakes can |
|
520 happen, so this function will walk the history of the given |
|
521 tag URL, stopping on the first revision that was created by |
|
522 copy. |
|
523 |
|
524 This detection is not foolproof. For example: it will be |
|
525 fooled by a tag that was created, deleted, and recreated by |
|
526 copy at a different revision. It is not clear what the desired |
|
527 behavior in these edge cases are, and no attempt is made to |
|
528 handle them. You should request user confirmation before using |
|
529 the result of this function. |
|
530 |
|
531 Args: |
|
532 url: The repository URL of the tag to examine. |
|
533 """ |
|
534 try: |
|
535 output = run(['svn', 'log', '-q', '--stop-on-copy', url], |
|
536 capture=True) |
|
537 except SubprocessFailed: |
|
538 raise ExpectationFailed('No tag at URL ' + url) |
|
539 first_rev_line = output[-2] |
|
540 first_rev = int(first_rev_line.split()[0][1:]) |
|
541 return first_rev |
|
542 |
|
543 @staticmethod |
|
544 def diff(url, revision): |
|
545 """Retrieve a revision from a remote repository as a unified diff. |
|
546 |
|
547 Args: |
|
548 url: The repository URL on which to perform the diff. |
|
549 revision: The revision to extract at the given url. |
|
550 |
|
551 Returns: |
|
552 A string containing the changes extracted from the remote |
|
553 repository, in unified diff format suitable for application |
|
554 using 'patch'. |
|
555 """ |
|
556 try: |
|
557 return run(['svn', 'diff', '-c', str(revision), url], |
|
558 capture=True, split_capture=False) |
|
559 except SubprocessFailed: |
|
560 raise ExpectationFailed('Could not get diff for r%d ' |
|
561 'from remote repository' % revision) |
|
562 |
|
563 |
|
564 # |
|
565 # Decorators for use in ReleaseEnvironment. |
|
566 # |
|
567 def pristine_wc(f): |
|
568 """A decorator that cleans up the release repository.""" |
|
569 @functools.wraps(f) |
|
570 def revert_wc(self, *args, **kwargs): |
|
571 self.wc.revert() |
|
572 return f(self, *args, **kwargs) |
|
573 return revert_wc |
|
574 |
|
575 |
|
576 def requires_branch(f): |
|
577 """A decorator that checks that a release branch is active.""" |
|
578 @functools.wraps(f) |
|
579 def check_branch(self, *args, **kwargs): |
|
580 if self.branch is None: |
|
581 raise ExpectationFailed( |
|
582 'This operation requires an active release branch') |
|
583 return f(self, *args, **kwargs) |
|
584 return check_branch |
|
585 |
|
586 |
|
587 class ReleaseEnvironment(Paths): |
|
588 """Encapsulates the state of a Melange release rolling environment. |
|
589 |
|
590 This class contains the actual releasing logic, and makes use of |
|
591 the previously defined utility classes to carry out user commands. |
|
592 |
|
593 Attributes: |
|
594 release_repos: The URL to the Google release repository root. |
|
595 upstream_repos: The URL to the Melange upstream repository root. |
|
596 wc: A Subversion object encapsulating a Google SoC working copy. |
|
597 """ |
|
598 |
|
599 BRANCH_FILE = 'BRANCH' |
|
600 |
|
601 def __init__(self, root, release_repos, upstream_repos): |
|
602 """Initializer. |
|
603 |
|
604 Args: |
|
605 root: The root of the release environment. |
|
606 release_repos: The URL to the Google release repository root. |
|
607 upstream_repos: The URL to the Melange upstream repository root. |
|
608 """ |
|
609 Paths.__init__(self, root) |
|
610 self.wc = Subversion(self.path('google-soc')) |
|
611 self.release_repos = release_repos.strip('/') |
|
612 self.upstream_repos = upstream_repos.strip('/') |
|
613 |
|
614 if not self.wc.exists(): |
|
615 self._InitializeWC() |
|
616 else: |
|
617 self.wc.revert() |
|
618 |
|
619 if self.exists(self.BRANCH_FILE): |
|
620 branch = fileToLines(self.path(self.BRANCH_FILE))[0] |
|
621 self._switchBranch(branch) |
|
622 else: |
|
623 self._switchBranch(None) |
|
624 |
|
625 def _InitializeWC(self): |
|
626 """Check out the initial release repository. |
|
627 |
|
628 Will also select the latest release branch, if any, so that |
|
629 the end state is a fully ready to function release |
|
630 environment. |
|
631 """ |
|
632 info('Checking out the release repository') |
|
633 |
|
634 # Check out a sparse view of the relevant repository paths. |
|
635 self.wc.checkout(self.release_repos, depth='immediates') |
|
636 self.wc.update('vendor', depth='immediates') |
|
637 self.wc.update('branches', depth='immediates') |
|
638 self.wc.update('tags', depth='immediates') |
|
639 |
|
640 # Locate the most recent release branch, if any, and switch |
|
641 # the release environment to it. |
|
642 branches = self._listBranches() |
|
643 if not branches: |
|
644 self._switchBranch(None) |
|
645 else: |
|
646 self._switchBranch(branches[-1]) |
|
647 |
|
648 def _listBranches(self): |
|
649 """Return a list of available Melange release branches. |
|
650 |
|
651 Branches are returned in sorted order, from least recent to |
|
652 most recent in release number ordering. |
|
653 """ |
|
654 assert self.wc.exists('branches') |
|
655 branches = self.wc.ls('branches') |
|
656 |
|
657 # Some early release branches used a different naming scheme |
|
658 # that doesn't sort properly with new-style release names. We |
|
659 # filter those out here, along with empty lines. |
|
660 branches = [b.strip('/') for b in branches |
|
661 if MELANGE_RELEASE_RE.match(b.strip('/'))] |
|
662 |
|
663 return sorted(branches) |
|
664 |
|
665 def _switchBranch(self, release): |
|
666 """Activate the branch matching the given release. |
|
667 |
|
668 Once activated, this branch is the target of future release |
|
669 operations. |
|
670 |
|
671 None can be passed as the release. The result is that no |
|
672 branch is active, and all operations that require an active |
|
673 branch will fail until a branch is activated again. This is |
|
674 used only at initialization, when it is detected that there |
|
675 are no available release branches to activate. |
|
676 |
|
677 Args: |
|
678 release: The version number of a Melange release already |
|
679 imported in the release repository, or None to |
|
680 activate no branch. |
|
681 |
|
682 """ |
|
683 if release is None: |
|
684 self.branch = None |
|
685 self.branch_dir = None |
|
686 info('No release branch available') |
|
687 else: |
|
688 self.wc.update() |
|
689 assert self.wc.exists('branches/' + release) |
|
690 linesToFile(self.path(self.BRANCH_FILE), [release]) |
|
691 self.branch = release |
|
692 self.branch_dir = 'branches/' + release |
|
693 self.wc.update(self.branch_dir, depth='infinity') |
|
694 info('Working on branch ' + self.branch) |
|
695 |
|
696 def _branchPath(self, path): |
|
697 """Return the given path with the release branch path prepended.""" |
|
698 assert self.branch_dir is not None |
|
699 return os.path.join(self.branch_dir, path) |
|
700 |
|
701 # |
|
702 # Release engineering commands. See further down for their |
|
703 # integration into a commandline interface. |
|
704 # |
|
705 @pristine_wc |
|
706 def update(self): |
|
707 """Update and clean the release repository""" |
|
708 self.wc.update() |
|
709 |
|
710 @pristine_wc |
|
711 def switchToBranch(self): |
|
712 """Switch to another Melange release branch""" |
|
713 branches = self._listBranches() |
|
714 if not branches: |
|
715 raise ExpectationFailed( |
|
716 'No branches available. Please import one.') |
|
717 |
|
718 choice = getChoice('Available release branches:', |
|
719 'Your choice?', |
|
720 branches, |
|
721 suggest=len(branches)-1) |
|
722 self._switchBranch(branches[choice]) |
|
723 |
|
724 def _addAppYaml(self): |
|
725 """Create a Google production app.yaml configuration. |
|
726 |
|
727 The file is copied and modified from the upstream |
|
728 app.yaml.template, configure for Google's Summer of Code App |
|
729 Engine instance, and committed. |
|
730 """ |
|
731 if self.wc.exists(self._branchPath('app/app.yaml')): |
|
732 raise ObstructionError('app/app.yaml exists already') |
|
733 |
|
734 yaml_path = self._branchPath('app/app.yaml') |
|
735 self.wc.copy(yaml_path + '.template', yaml_path) |
|
736 |
|
737 yaml = fileToLines(self.wc.path(yaml_path)) |
|
738 out = [] |
|
739 for i, line in enumerate(yaml): |
|
740 stripped_line = line.strip() |
|
741 if 'TODO' in stripped_line: |
|
742 continue |
|
743 elif stripped_line == '# application: FIXME': |
|
744 out.append('application: socghop') |
|
745 elif stripped_line.startswith('version:'): |
|
746 out.append(line.lstrip() + 'g0') |
|
747 out.append('# * initial Google fork of Melange ' + |
|
748 self.branch) |
|
749 else: |
|
750 out.append(line) |
|
751 linesToFile(self.wc.path(yaml_path), out) |
|
752 |
|
753 self.wc.commit('Create app.yaml with Google patch version g0 ' |
|
754 'in branch ' + self.branch) |
|
755 |
|
756 def _applyGooglePatches(self): |
|
757 """Apply Google-specific patches to a vanilla Melange release. |
|
758 |
|
759 Each patch is applied and committed in turn. |
|
760 """ |
|
761 # Edit the base template to point users to the Google fork |
|
762 # of the Melange codebase instead of the vanilla release. |
|
763 tmpl_file = self.wc.path( |
|
764 self._branchPath('app/soc/templates/soc/base.html')) |
|
765 tmpl = fileToLines(tmpl_file) |
|
766 for i, line in enumerate(tmpl): |
|
767 if 'http://code.google.com/p/soc/source/browse/tags/' in line: |
|
768 tmpl[i] = line.replace('/p/soc/', '/p/soc-google/') |
|
769 break |
|
770 else: |
|
771 raise ExpectationFailed( |
|
772 'No source code link found in base.html') |
|
773 linesToFile(tmpl_file, tmpl) |
|
774 |
|
775 self.wc.commit( |
|
776 'Customize the Melange release link in the sidebar menu') |
|
777 |
|
778 @pristine_wc |
|
779 def importTag(self): |
|
780 """Import a new Melange release""" |
|
781 release = getString('Enter the Melange release to import:') |
|
782 if not release: |
|
783 AbortedByUser('No release provided, import aborted') |
|
784 |
|
785 branch_dir = 'branches/' + release |
|
786 if self.wc.exists(branch_dir): |
|
787 raise ObstructionError('Release %s already imported' % release) |
|
788 |
|
789 tag_url = '%s/tags/%s' % (self.upstream_repos, release) |
|
790 release_rev = Subversion.find_tag_rev(tag_url) |
|
791 |
|
792 if confirm('Confirm import of release %s, tagged at r%d?' % |
|
793 (release, release_rev)): |
|
794 # Add an entry to the vendor externals for the Melange |
|
795 # release. |
|
796 externals = self.wc.propget('svn:externals', 'vendor/soc') |
|
797 externals.append('%s -r %d %s' % (release, release_rev, tag_url)) |
|
798 self.wc.propset('svn:externals', '\n'.join(externals), 'vendor/soc') |
|
799 self.wc.commit('Add svn:externals entry to pull in Melange ' |
|
800 'release %s at r%d.' % (release, release_rev)) |
|
801 |
|
802 # Export the tag into the release repository's branches |
|
803 Subversion.export(tag_url, release_rev, self.wc.path(branch_dir)) |
|
804 |
|
805 # Add and commit the branch add (very long operation!) |
|
806 self.wc.add([branch_dir]) |
|
807 self.wc.commit('Branch of Melange release %s' % release, |
|
808 branch_dir) |
|
809 self._switchBranch(release) |
|
810 |
|
811 # Commit the production GSoC configuration and |
|
812 # google-specific patches. |
|
813 self._addAppYaml() |
|
814 self._applyGooglePatches() |
|
815 |
|
816 # All done! |
|
817 info('Melange release %s imported and googlified' % self.branch) |
|
818 |
|
819 @requires_branch |
|
820 @pristine_wc |
|
821 def cherryPickChange(self): |
|
822 """Cherry-pick a change from the Melange trunk""" |
|
823 rev = getNumber('Revision number to cherry-pick:') |
|
824 bug = getNumber('Issue fixed by this change:') |
|
825 |
|
826 diff = self.wc.diff(self.upstream_repos + '/trunk', rev) |
|
827 if not diff.strip(): |
|
828 raise ExpectationFailed( |
|
829 'Retrieved diff is empty. ' |
|
830 'Did you accidentally cherry-pick a branch change?') |
|
831 run(['patch', '-p0'], cwd=self.wc.path(self.branch_dir), stdin=diff) |
|
832 self.wc.addRemove(self.branch_dir) |
|
833 |
|
834 yaml_path = self.wc.path(self._branchPath('app/app.yaml')) |
|
835 out = [] |
|
836 updated_patchlevel = False |
|
837 for line in fileToLines(yaml_path): |
|
838 if line.strip().startswith('version: '): |
|
839 version = line.strip().split()[-1] |
|
840 base, patch = line.rsplit('g', 1) |
|
841 new_version = '%sg%d' % (base, int(patch) + 1) |
|
842 message = ('Cherry-picked r%d from /p/soc/ to fix issue %d' % |
|
843 (rev, bug)) |
|
844 out.append('version: ' + new_version) |
|
845 out.append('# * ' + message) |
|
846 updated_patchlevel = True |
|
847 else: |
|
848 out.append(line) |
|
849 |
|
850 if not updated_patchlevel: |
|
851 error('Failed to update Google patch revision') |
|
852 error('Cherry-picking failed') |
|
853 |
|
854 linesToFile(yaml_path, out) |
|
855 |
|
856 info('Check the diff about to be committed with:') |
|
857 info('svn diff ' + self.wc.path(self.branch_dir)) |
|
858 if not confirm('Commit this change?'): |
|
859 raise AbortedByUser('Cherry-pick aborted') |
|
860 self.wc.commit(message) |
|
861 info('Cherry-picked r%d from the Melange trunk.' % rev) |
|
862 |
|
863 MENU_ORDER = [ |
|
864 update, |
|
865 switchToBranch, |
|
866 importTag, |
|
867 cherryPickChange, |
|
868 ] |
|
869 |
|
870 MENU_STRINGS = [d.__doc__ for d in MENU_ORDER] |
|
871 |
|
872 MENU_SUGGESTIONS = { |
|
873 None: update, |
|
874 update: cherryPickChange, |
|
875 switchToBranch: cherryPickChange, |
|
876 importTag: cherryPickChange, |
|
877 cherryPickChange: None, |
|
878 } |
|
879 |
|
880 def interactiveMenu(self): |
|
881 done = [] |
|
882 last_choice = None |
|
883 while True: |
|
884 # Show the user their previously completed operations and |
|
885 # a suggested next op, to remind them where they are in |
|
886 # the release process (useful after long operations that |
|
887 # may have caused lunch or an extended context switch). |
|
888 if last_choice is not None: |
|
889 last_command = self.MENU_ORDER[last_choice] |
|
890 else: |
|
891 last_command = None |
|
892 suggested_next = self.MENU_ORDER.index( |
|
893 self.MENU_SUGGESTIONS[last_command]) |
|
894 |
|
895 try: |
|
896 choice = getChoice('Main menu:', 'Your choice?', |
|
897 self.MENU_STRINGS, done=done, |
|
898 suggest=suggested_next) |
|
899 except (KeyboardInterrupt, AbortedByUser): |
|
900 info('Exiting.') |
|
901 return |
|
902 try: |
|
903 self.MENU_ORDER[choice](self) |
|
904 except Error, e: |
|
905 error(str(e)) |
|
906 else: |
|
907 done.append(choice) |
|
908 last_choice = choice |
|
909 |
|
910 |
|
911 def main(argv): |
|
912 if not (1 <= len(argv) <= 3): |
|
913 print ('Usage: gsoc-release.py [release repos root URL] ' |
|
914 '[upstream repos root URL]') |
|
915 sys.exit(1) |
|
916 |
|
917 release_repos, upstream_repos = GOOGLE_SOC_REPOS, MELANGE_REPOS |
|
918 if len(argv) >= 2: |
|
919 release_repos = argv[1] |
|
920 if len(argv) == 3: |
|
921 upstream_repos = argv[2] |
|
922 |
|
923 info('Release repository: ' + release_repos) |
|
924 info('Upstream repository: ' + upstream_repos) |
|
925 |
|
926 r = ReleaseEnvironment(os.path.abspath('_release_'), |
|
927 release_repos, |
|
928 upstream_repos) |
|
929 r.interactiveMenu() |
|
930 |
|
931 |
|
932 if __name__ == '__main__': |
|
933 main(sys.argv) |