xref: /Lucene/dev-tools/scripts/buildAndPushRelease.py (revision f605b4a692789e6b76a784a616d871db710823a8)
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# Licensed to the Apache Software Foundation (ASF) under one or more
4# contributor license agreements.  See the NOTICE file distributed with
5# this work for additional information regarding copyright ownership.
6# The ASF licenses this file to You under the Apache License, Version 2.0
7# (the "License"); you may not use this file except in compliance with
8# the License.  You may obtain a copy of the License at
9#
10#     http://www.apache.org/licenses/LICENSE-2.0
11#
12# Unless required by applicable law or agreed to in writing, software
13# distributed under the License is distributed on an "AS IS" BASIS,
14# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15# See the License for the specific language governing permissions and
16# limitations under the License.
17
18import argparse
19import datetime
20import re
21import time
22import os
23import sys
24import subprocess
25from subprocess import TimeoutExpired
26import textwrap
27import urllib.request, urllib.error, urllib.parse
28import xml.etree.ElementTree as ET
29
30import scriptutil
31
32LOG = '/tmp/release.log'
33dev_mode = False
34
35def log(msg):
36  f = open(LOG, mode='ab')
37  f.write(msg.encode('utf-8'))
38  f.close()
39
40def run(command):
41  log('\n\n%s: RUN: %s\n' % (datetime.datetime.now(), command))
42  if os.system('%s >> %s 2>&1' % (command, LOG)):
43    msg = '    FAILED: %s [see log %s]' % (command, LOG)
44    print(msg)
45    raise RuntimeError(msg)
46
47
48def runAndSendGPGPassword(command, password):
49  p = subprocess.Popen(command, shell=True, bufsize=0, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.PIPE)
50  f = open(LOG, 'ab')
51  while True:
52    p.stdout.flush()
53    line = p.stdout.readline()
54    if len(line) == 0:
55      break
56    f.write(line)
57    if line.find(b'Enter GPG keystore password:') != -1:
58      time.sleep(1.0)
59      p.stdin.write((password + '\n').encode('UTF-8'))
60      p.stdin.write('\n'.encode('UTF-8'))
61
62  try:
63    result = p.wait(timeout=120)
64    if result != 0:
65      msg = '    FAILED: %s [see log %s]' % (command, LOG)
66      print(msg)
67      raise RuntimeError(msg)
68  except TimeoutExpired:
69    msg = '    FAILED: %s [timed out after 2 minutes; see log %s]' % (command, LOG)
70    print(msg)
71    raise RuntimeError(msg)
72
73def load(urlString, encoding="utf-8"):
74  try:
75    content = urllib.request.urlopen(urlString).read().decode(encoding)
76  except Exception as e:
77    print('Retrying download of url %s after exception: %s' % (urlString, e))
78    content = urllib.request.urlopen(urlString).read().decode(encoding)
79  return content
80
81def getGitRev():
82  if not dev_mode:
83    status = os.popen('git status').read().strip()
84    if 'nothing to commit, working directory clean' not in status and 'nothing to commit, working tree clean' not in status:
85      raise RuntimeError('git clone is dirty:\n\n%s' % status)
86    if 'Your branch is ahead of' in status:
87      raise RuntimeError('Your local branch is ahead of the remote? git status says:\n%s' % status)
88    print('  git clone is clean')
89  else:
90    print('  Ignoring dirty git clone due to dev-mode')
91  return os.popen('git rev-parse HEAD').read().strip()
92
93
94def prepare(root, version, gpg_key_id, gpg_password, gpg_home=None, sign_gradle=False):
95  print()
96  print('Prepare release...')
97  if os.path.exists(LOG):
98    os.remove(LOG)
99
100  if not dev_mode:
101    os.chdir(root)
102    print('  git pull...')
103    run('git pull')
104  else:
105    print('  Development mode, not running git pull')
106
107  rev = getGitRev()
108  print('  git rev: %s' % rev)
109  log('\nGIT rev: %s\n' % rev)
110
111  print('  Check DOAP files')
112  checkDOAPfiles(version)
113
114  if not dev_mode:
115    print('  ./gradlew --no-daemon clean check')
116    run('./gradlew --no-daemon clean check')
117  else:
118    print('  skipping precommit check due to dev-mode')
119
120  print('  prepare-release')
121  cmd = './gradlew --no-daemon assembleRelease' \
122        ' -Dversion.release=%s' % version
123  if dev_mode:
124    cmd += ' -Pvalidation.git.failOnModified=false'
125  if gpg_key_id is not None:
126    cmd += ' -Psign --max-workers 2'
127    if sign_gradle:
128      print("  Signing method is gradle java-plugin")
129      cmd += ' -Psigning.keyId="%s"' % gpg_key_id
130      if gpg_home is not None:
131        cmd += ' -Psigning.secretKeyRingFile="%s"' % os.path.join(gpg_home, 'secring.gpg')
132      if gpg_password is not None:
133        # Pass gpg passphrase as env.var to gradle rather than as plaintext argument
134        os.environ['ORG_GRADLE_PROJECT_signingPassword'] = gpg_password
135    else:
136      print("  Signing method is gpg tool")
137      cmd += ' -PuseGpg -Psigning.gnupg.keyName="%s"' % gpg_key_id
138      if gpg_home is not None:
139        cmd += ' -Psigning.gnupg.homeDir="%s"' % gpg_home
140
141  print("  Running: %s" % cmd)
142  if gpg_password is not None:
143    runAndSendGPGPassword(cmd, gpg_password)
144  else:
145    run(cmd)
146
147  print('  done!')
148  print()
149  return rev
150
151reVersion1 = re.compile(r'\>(\d+)\.(\d+)\.(\d+)(-alpha|-beta)?/\<', re.IGNORECASE)
152reVersion2 = re.compile(r'-(\d+)\.(\d+)\.(\d+)(-alpha|-beta)?\.zip<', re.IGNORECASE)
153reDoapRevision = re.compile(r'(\d+)\.(\d+)(?:\.(\d+))?(-alpha|-beta)?', re.IGNORECASE)
154def checkDOAPfiles(version):
155  # In Lucene DOAP file, verify presence of all releases less than the one being produced.
156  errorMessages = []
157  for product in ['lucene']:
158    url = 'https://archive.apache.org/dist/lucene/%s' % ('java' if product == 'lucene' else product)
159    distpage = load(url)
160    releases = set()
161    for regex in reVersion1, reVersion2:
162      for tup in regex.findall(distpage):
163        if tup[0] in ('1', '2'):                    # Ignore 1.X and 2.X releases
164          continue
165        releases.add(normalizeVersion(tup))
166    doapNS = '{http://usefulinc.com/ns/doap#}'
167    xpathRevision = '{0}Project/{0}release/{0}Version/{0}revision'.format(doapNS)
168    doapFile = "dev-tools/doap/%s.rdf" % product
169    treeRoot = ET.parse(doapFile).getroot()
170    doapRevisions = set()
171    for revision in treeRoot.findall(xpathRevision):
172      match = reDoapRevision.match(revision.text)
173      if (match is not None):
174        if (match.group(1) not in ('0', '1', '2')): # Ignore 0.X, 1.X and 2.X revisions
175          doapRevisions.add(normalizeVersion(match.groups()))
176      else:
177        errorMessages.append('ERROR: Failed to parse revision: %s in %s' % (revision.text, doapFile))
178    missingDoapRevisions = set()
179    for release in releases:
180      if release not in doapRevisions and release < version: # Ignore releases greater than the one being produced
181        missingDoapRevisions.add(release)
182    if len(missingDoapRevisions) > 0:
183      errorMessages.append('ERROR: Missing revision(s) in %s: %s' % (doapFile, ', '.join(sorted(missingDoapRevisions))))
184  if (len(errorMessages) > 0):
185    raise RuntimeError('\n%s\n(Hint: copy/paste from the stable branch version of the file(s).)'
186                       % '\n'.join(errorMessages))
187
188def normalizeVersion(tup):
189  suffix = ''
190  if tup[-1] is not None and tup[-1].lower() == '-alpha':
191    tup = tup[:(len(tup) - 1)]
192    suffix = '-ALPHA'
193  elif tup[-1] is not None and tup[-1].lower() == '-beta':
194    tup = tup[:(len(tup) - 1)]
195    suffix = '-BETA'
196  while tup[-1] in ('', None):
197    tup = tup[:(len(tup) - 1)]
198  while len(tup) < 3:
199    tup = tup + ('0',)
200  return '.'.join(tup) + suffix
201
202
203def pushLocal(version, root, rcNum, localDir):
204  print('Push local [%s]...' % localDir)
205  os.makedirs(localDir)
206
207  lucene_dist_dir = '%s/lucene/distribution/build/release' % root
208  rev = open('%s/lucene/distribution/build/release/.gitrev' % root, encoding='UTF-8').read()
209
210  dir = 'lucene-%s-RC%d-rev-%s' % (version, rcNum, rev)
211  os.makedirs('%s/%s/lucene' % (localDir, dir))
212  print('  Lucene')
213  os.chdir(lucene_dist_dir)
214  print('    archive...')
215  if os.path.exists('lucene.tar'):
216    os.remove('lucene.tar')
217  run('tar cf lucene.tar *')
218
219  os.chdir('%s/%s/lucene' % (localDir, dir))
220  print('    extract...')
221  run('tar xf "%s/lucene.tar"' % lucene_dist_dir)
222  os.remove('%s/lucene.tar' % lucene_dist_dir)
223  os.chdir('..')
224
225  print('  chmod...')
226  run('chmod -R a+rX-w .')
227
228  print('  done!')
229  return 'file://%s/%s' % (os.path.abspath(localDir), dir)
230
231
232def read_version(path):
233  return scriptutil.find_current_version()
234
235
236def parse_config():
237  epilogue = textwrap.dedent('''
238    Example usage for a Release Manager:
239    python3 -u dev-tools/scripts/buildAndPushRelease.py --push-local /tmp/releases/6.0.1 --sign 6E68DA61 --rc-num 1
240  ''')
241  description = 'Utility to build, push, and test a release.'
242  parser = argparse.ArgumentParser(description=description, epilog=epilogue,
243                                   formatter_class=argparse.RawDescriptionHelpFormatter)
244  parser.add_argument('--no-prepare', dest='prepare', default=True, action='store_false',
245                      help='Use the already built release in the provided checkout')
246  parser.add_argument('--local-keys', metavar='PATH',
247                      help='Uses local KEYS file to validate presence of RM\'s gpg key')
248  parser.add_argument('--push-local', metavar='PATH',
249                      help='Push the release to the local path')
250  parser.add_argument('--sign', metavar='KEYID',
251                      help='Sign the release with the given gpg key')
252  parser.add_argument('--sign-method-gradle', dest='sign_method_gradle', default=False, action='store_true',
253                      help='Use Gradle built-in GPG signing instead of gpg command for signing artifacts. '
254                      ' This may require --gpg-secring argument if your keychain cannot be resolved automatically.')
255  parser.add_argument('--gpg-pass-noprompt', dest='gpg_pass_noprompt', default=False, action='store_true',
256                      help='Do not prompt for gpg passphrase. For the default gnupg method, this means your gpg-agent'
257                      ' needs a non-TTY pin-entry program. For gradle signing method, passphrase must be provided'
258                      ' in gradle.properties or by env.var/sysprop. See ./gradlew helpPublishing for more info')
259  parser.add_argument('--gpg-home', metavar='PATH',
260                      help='Path to gpg home containing your secring.gpg'
261                      ' Optional, will use $HOME/.gnupg/secring.gpg by default')
262  parser.add_argument('--rc-num', metavar='NUM', type=int, default=1,
263                      help='Release Candidate number.  Default: 1')
264  parser.add_argument('--root', metavar='PATH', default='.',
265                      help='Root of Git working tree for lucene.  Default: "." (the current directory)')
266  parser.add_argument('--logfile', metavar='PATH',
267                      help='Specify log file path (default /tmp/release.log)')
268  parser.add_argument('--dev-mode', default=False, action='store_true',
269                      help='Enable development mode, which disables some strict checks')
270  config = parser.parse_args()
271
272  if not config.prepare and config.sign:
273    parser.error('Cannot sign already built release')
274  if config.push_local is not None and os.path.exists(config.push_local):
275    parser.error('Cannot push to local path that already exists')
276  if config.rc_num <= 0:
277    parser.error('Release Candidate number must be a positive integer')
278  if not os.path.isdir(config.root):
279    parser.error('Root path "%s" is not a directory' % config.root)
280  if config.local_keys is not None and not os.path.exists(config.local_keys):
281    parser.error('Local KEYS file "%s" not found' % config.local_keys)
282  if config.gpg_home and not os.path.exists(os.path.join(config.gpg_home, 'secring.gpg')):
283    parser.error('Specified gpg home %s does not exist or does not contain a secring.gpg' % config.gpg_home)
284  global dev_mode
285  if config.dev_mode:
286    print("Enabling development mode - DO NOT USE FOR ACTUAL RELEASE!")
287    dev_mode = True
288  cwd = os.getcwd()
289  os.chdir(config.root)
290  config.root = os.getcwd() # Absolutize root dir
291  if os.system('git rev-parse') or 2 != len([d for d in ('dev-tools','lucene') if os.path.isdir(d)]):
292    parser.error('Root path "%s" is not a valid lucene checkout' % config.root)
293  os.chdir(cwd)
294  global LOG
295  if config.logfile:
296    LOG = config.logfile
297  print("Logfile is: %s" % LOG)
298
299  config.version = read_version(config.root)
300  print('Building version: %s' % config.version)
301
302  return config
303
304def check_cmdline_tools():  # Fail fast if there are cmdline tool problems
305  if os.system('git --version >/dev/null 2>/dev/null'):
306    raise RuntimeError('"git --version" returned a non-zero exit code.')
307
308def check_key_in_keys(gpgKeyID, local_keys):
309  if gpgKeyID is not None:
310    print('  Verify your gpg key is in the main KEYS file')
311    if local_keys is not None:
312      print("    Using local KEYS file %s" % local_keys)
313      keysFileText = open(local_keys, encoding='iso-8859-1').read()
314      keysFileLocation = local_keys
315    else:
316      keysFileURL = "https://archive.apache.org/dist/lucene/KEYS"
317      keysFileLocation = keysFileURL
318      print("    Using online KEYS file %s" % keysFileURL)
319      keysFileText = load(keysFileURL, encoding='iso-8859-1')
320    if len(gpgKeyID) > 2 and gpgKeyID[0:2] == '0x':
321      gpgKeyID = gpgKeyID[2:]
322    if len(gpgKeyID) > 40:
323      gpgKeyID = gpgKeyID.replace(" ", "")
324    if len(gpgKeyID) == 8:
325      gpgKeyID8Char = "%s %s" % (gpgKeyID[0:4], gpgKeyID[4:8])
326      re_to_match = r"^pub .*\n\s+(\w{4} \w{4} \w{4} \w{4} \w{4}  \w{4} \w{4} \w{4} %s|\w{32}%s)" % (gpgKeyID8Char, gpgKeyID)
327    elif len(gpgKeyID) == 40:
328      gpgKeyID40Char = "%s %s %s %s %s  %s %s %s %s %s" % \
329                       (gpgKeyID[0:4], gpgKeyID[4:8], gpgKeyID[8:12], gpgKeyID[12:16], gpgKeyID[16:20],
330                       gpgKeyID[20:24], gpgKeyID[24:28], gpgKeyID[28:32], gpgKeyID[32:36], gpgKeyID[36:])
331      re_to_match = r"^pub .*\n\s+(%s|%s)" % (gpgKeyID40Char, gpgKeyID)
332    else:
333      print('Invalid gpg key id format [%s]. Must be 8 byte short ID or 40 byte fingerprint, with or without 0x prefix, no spaces.' % gpgKeyID)
334      exit(2)
335    if re.search(re_to_match, keysFileText, re.MULTILINE):
336      print('    Found key %s in KEYS file at %s' % (gpgKeyID, keysFileLocation))
337    else:
338      print('    ERROR: Did not find your key %s in KEYS file at %s. Please add it and try again.' % (gpgKeyID, keysFileLocation))
339      if local_keys is not None:
340        print('           You are using a local KEYS file. Make sure it is up to date or validate against the online version')
341      exit(2)
342
343
344def resolve_gpghome():
345  for p in [
346    # Linux, macos
347    os.path.join(os.path.expanduser("~"), '.gnupg'),
348    # Windows 10
349    os.path.expandvars(r'%APPDATA%\GnuPG')
350    # TODO: Should we support Cygwin?
351  ]:
352    if os.path.exists(os.path.join(p, 'secring.gpg')):
353      return p
354  return None
355
356
357def main():
358  check_cmdline_tools()
359
360  c = parse_config()
361  gpg_home = None
362
363  if c.sign:
364    sys.stdout.flush()
365    c.key_id = c.sign
366    check_key_in_keys(c.key_id, c.local_keys)
367    if c.gpg_home is not None:
368      print("Using custom gpg-home: %s" % c.gpg_home)
369      gpg_home = c.gpg_home
370    if c.sign_method_gradle:
371      if gpg_home is None:
372        resolved_gpg_home = resolve_gpghome()
373        if resolved_gpg_home is not None:
374          print("Resolved gpg home to %s" % resolved_gpg_home)
375          gpg_home = resolved_gpg_home
376        else:
377          print("WARN: Could not locate your gpg secret keyring, and --gpg-home not specified.")
378          print("      Falling back to location configured in gradle.properties.")
379          print("      See 'gradlew helpPublishing' for details.")
380          gpg_home = None
381    if c.gpg_pass_noprompt:
382      print("Will not prompt for gpg password. Make sure your signing setup supports this.")
383      c.key_password = None
384    else:
385      import getpass
386      c.key_password = getpass.getpass('Enter GPG keystore password: ')
387  else:
388    c.key_id = None
389    c.key_password = None
390
391  if c.prepare:
392    prepare(c.root, c.version, c.key_id, c.key_password, gpg_home=gpg_home, sign_gradle=c.sign_method_gradle)
393  else:
394    os.chdir(c.root)
395
396  if c.push_local:
397    url = pushLocal(c.version, c.root, c.rc_num, c.push_local)
398  else:
399    url = None
400
401  if url is not None:
402    print('  URL: %s' % url)
403    print('Next run the smoker tester:')
404    p = re.compile(".*/")
405    m = p.match(sys.argv[0])
406    if not c.sign:
407      signed = "--not-signed"
408    else:
409      signed = ""
410    print('%s -u %ssmokeTestRelease.py %s %s' % (sys.executable, m.group(), signed, url))
411
412if __name__ == '__main__':
413  try:
414    main()
415  except KeyboardInterrupt:
416    print('Keyboard interrupt...exiting')
417
418