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