File: buildAndPushRelease.py

package info (click to toggle)
lucene9 9.10.0%2Bdfsg-2
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 114,036 kB
  • sloc: java: 775,336; python: 6,157; xml: 1,464; perl: 448; sh: 353; makefile: 11
file content (422 lines) | stat: -rwxr-xr-x 16,880 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements.  See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License.  You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import argparse
import datetime
import re
import time
import os
import sys
import subprocess
from subprocess import TimeoutExpired
import textwrap
import urllib.request, urllib.error, urllib.parse
import xml.etree.ElementTree as ET

import scriptutil

LOG = '/tmp/release.log'
dev_mode = False

def log(msg):
  f = open(LOG, mode='ab')
  f.write(msg.encode('utf-8'))
  f.close()

def run(command):
  log('\n\n%s: RUN: %s\n' % (datetime.datetime.now(), command))
  if os.system('%s >> %s 2>&1' % (command, LOG)):
    msg = '    FAILED: %s [see log %s]' % (command, LOG)
    print(msg)
    raise RuntimeError(msg)


def runAndSendGPGPassword(command, password):
  p = subprocess.Popen(command, shell=True, bufsize=0, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, stdin=subprocess.PIPE)
  f = open(LOG, 'ab')
  while True:
    p.stdout.flush()
    line = p.stdout.readline()
    if len(line) == 0:
      break
    f.write(line)
    if line.find(b'Enter GPG keystore password:') != -1:
      time.sleep(1.0)
      p.stdin.write((password + '\n').encode('UTF-8'))
      p.stdin.write('\n'.encode('UTF-8'))

  try:
    result = p.wait(timeout=120)
    if result != 0:
      msg = '    FAILED: %s [see log %s]' % (command, LOG)
      print(msg)
      raise RuntimeError(msg)
  except TimeoutExpired:
    msg = '    FAILED: %s [timed out after 2 minutes; see log %s]' % (command, LOG)
    print(msg)
    raise RuntimeError(msg)

def load(urlString, encoding="utf-8"):
  try:
    content = urllib.request.urlopen(urlString).read().decode(encoding)
  except Exception as e:
    print('Retrying download of url %s after exception: %s' % (urlString, e))
    content = urllib.request.urlopen(urlString).read().decode(encoding)
  return content

def getGitRev():
  if not dev_mode:
    status = os.popen('git status').read().strip()
    if 'nothing to commit, working directory clean' not in status and 'nothing to commit, working tree clean' not in status:
      raise RuntimeError('git clone is dirty:\n\n%s' % status)
    if 'Your branch is ahead of' in status:
      raise RuntimeError('Your local branch is ahead of the remote? git status says:\n%s' % status)
    print('  git clone is clean')
  else:
    print('  Ignoring dirty git clone due to dev-mode')
  return os.popen('git rev-parse HEAD').read().strip()


def prepare(root, version, pause_before_sign, gpg_key_id, gpg_password, gpg_home=None, sign_gradle=False):
  print()
  print('Prepare release...')
  if os.path.exists(LOG):
    os.remove(LOG)

  if not dev_mode:
    os.chdir(root)
    print('  git pull...')
    run('git pull')
  else:
    print('  Development mode, not running git pull')

  rev = getGitRev()
  print('  git rev: %s' % rev)
  log('\nGIT rev: %s\n' % rev)

  print('  Check DOAP files')
  checkDOAPfiles(version)

  if not dev_mode:
    print('  ./gradlew --no-daemon -Dtests.badapples=false clean check')
    run('./gradlew --no-daemon -Dtests.badapples=false clean check')
  else:
    print('  skipping precommit check due to dev-mode')

  if pause_before_sign:
    input("Tests complete! Please press ENTER to proceed to assembleRelease: ")

  print('  prepare-release')
  cmd = './gradlew --no-daemon assembleRelease' \
        ' -Dversion.release=%s' % version
  if dev_mode:
    cmd += ' -Pvalidation.git.failOnModified=false'
  if gpg_key_id is not None:
    cmd += ' -Psign --max-workers 2'
    if sign_gradle:
      print("  Signing method is gradle java-plugin")
      cmd += ' -Psigning.keyId="%s"' % gpg_key_id
      if gpg_home is not None:
        cmd += ' -Psigning.secretKeyRingFile="%s"' % os.path.join(gpg_home, 'secring.gpg')
      if gpg_password is not None:
        # Pass gpg passphrase as env.var to gradle rather than as plaintext argument
        os.environ['ORG_GRADLE_PROJECT_signingPassword'] = gpg_password
    else:
      print("  Signing method is gpg tool")
      cmd += ' -PuseGpg -Psigning.gnupg.keyName="%s"' % gpg_key_id
      if gpg_home is not None:
        cmd += ' -Psigning.gnupg.homeDir="%s"' % gpg_home

  print("  Running: %s" % cmd)
  if gpg_password is not None:
    runAndSendGPGPassword(cmd, gpg_password)
  else:
    run(cmd)

  print('  done!')
  print()
  return rev

reVersion1 = re.compile(r'\>(\d+)\.(\d+)\.(\d+)(-alpha|-beta)?/\<', re.IGNORECASE)
reVersion2 = re.compile(r'-(\d+)\.(\d+)\.(\d+)(-alpha|-beta)?\.zip<', re.IGNORECASE)
reDoapRevision = re.compile(r'(\d+)\.(\d+)(?:\.(\d+))?(-alpha|-beta)?', re.IGNORECASE)
def checkDOAPfiles(version):
  # In Lucene DOAP file, verify presence of all releases less than the one being produced.
  errorMessages = []
  for product in ['lucene']:
    url = 'https://archive.apache.org/dist/lucene/%s' % ('java' if product == 'lucene' else product)
    distpage = load(url)
    releases = set()
    for regex in reVersion1, reVersion2:
      for tup in regex.findall(distpage):
        if tup[0] in ('1', '2'):                    # Ignore 1.X and 2.X releases
          continue
        releases.add(normalizeVersion(tup))
    doapNS = '{http://usefulinc.com/ns/doap#}'
    xpathRevision = '{0}Project/{0}release/{0}Version/{0}revision'.format(doapNS)
    doapFile = "dev-tools/doap/%s.rdf" % product
    treeRoot = ET.parse(doapFile).getroot()
    doapRevisions = set()
    for revision in treeRoot.findall(xpathRevision):
      match = reDoapRevision.match(revision.text)
      if (match is not None):
        if (match.group(1) not in ('0', '1', '2')): # Ignore 0.X, 1.X and 2.X revisions
          doapRevisions.add(normalizeVersion(match.groups()))
      else:
        errorMessages.append('ERROR: Failed to parse revision: %s in %s' % (revision.text, doapFile))
    missingDoapRevisions = set()
    for release in releases:
      if release not in doapRevisions and release < version: # Ignore releases greater than the one being produced
        missingDoapRevisions.add(release)
    if len(missingDoapRevisions) > 0:
      errorMessages.append('ERROR: Missing revision(s) in %s: %s' % (doapFile, ', '.join(sorted(missingDoapRevisions))))
  if (len(errorMessages) > 0):
    raise RuntimeError('\n%s\n(Hint: copy/paste from the stable branch version of the file(s).)'
                       % '\n'.join(errorMessages))

def normalizeVersion(tup):
  suffix = ''
  if tup[-1] is not None and tup[-1].lower() == '-alpha':
    tup = tup[:(len(tup) - 1)]
    suffix = '-ALPHA'
  elif tup[-1] is not None and tup[-1].lower() == '-beta':
    tup = tup[:(len(tup) - 1)]
    suffix = '-BETA'
  while tup[-1] in ('', None):
    tup = tup[:(len(tup) - 1)]
  while len(tup) < 3:
    tup = tup + ('0',)
  return '.'.join(tup) + suffix


def pushLocal(version, root, rcNum, localDir):
  print('Push local [%s]...' % localDir)
  os.makedirs(localDir)

  lucene_dist_dir = '%s/lucene/distribution/build/release' % root
  rev = open('%s/lucene/distribution/build/release/.gitrev' % root, encoding='UTF-8').read()

  dir = 'lucene-%s-RC%d-rev-%s' % (version, rcNum, rev)
  os.makedirs('%s/%s/lucene' % (localDir, dir))
  print('  Lucene')
  os.chdir(lucene_dist_dir)
  print('    archive...')
  if os.path.exists('lucene.tar'):
    os.remove('lucene.tar')
  run('tar cf lucene.tar *')

  os.chdir('%s/%s/lucene' % (localDir, dir))
  print('    extract...')
  run('tar xf "%s/lucene.tar"' % lucene_dist_dir)
  os.remove('%s/lucene.tar' % lucene_dist_dir)
  os.chdir('..')

  print('  chmod...')
  run('chmod -R a+rX-w .')

  print('  done!')
  return 'file://%s/%s' % (os.path.abspath(localDir), dir)


def read_version(path):
  return scriptutil.find_current_version()


def parse_config():
  epilogue = textwrap.dedent('''
    Example usage for a Release Manager:
    python3 -u dev-tools/scripts/buildAndPushRelease.py --push-local /tmp/releases/6.0.1 --sign 6E68DA61 --rc-num 1
  ''')
  description = 'Utility to build, push, and test a release.'
  parser = argparse.ArgumentParser(description=description, epilog=epilogue,
                                   formatter_class=argparse.RawDescriptionHelpFormatter)
  parser.add_argument('--no-prepare', dest='prepare', default=True, action='store_false',
                      help='Use the already built release in the provided checkout')
  parser.add_argument('--local-keys', metavar='PATH',
                      help='Uses local KEYS file to validate presence of RM\'s gpg key')
  parser.add_argument('--push-local', metavar='PATH',
                      help='Push the release to the local path')
  parser.add_argument('--pause-before-sign', default=False, action='store_true',
                      help='Pause for user confirmation before the assembleRelease step (to prevent timeout on gpg pinentry')
  parser.add_argument('--sign', metavar='KEYID',
                      help='Sign the release with the given gpg key')
  parser.add_argument('--sign-method-gradle', dest='sign_method_gradle', default=False, action='store_true',
                      help='Use Gradle built-in GPG signing instead of gpg command for signing artifacts. '
                      ' This may require --gpg-secring argument if your keychain cannot be resolved automatically.')
  parser.add_argument('--gpg-pass-noprompt', dest='gpg_pass_noprompt', default=False, action='store_true',
                      help='Do not prompt for gpg passphrase. For the default gnupg method, this means your gpg-agent'
                      ' needs a non-TTY pin-entry program. For gradle signing method, passphrase must be provided'
                      ' in gradle.properties or by env.var/sysprop. See ./gradlew helpPublishing for more info')
  parser.add_argument('--gpg-home', metavar='PATH',
                      help='Path to gpg home containing your secring.gpg'
                      ' Optional, will use $HOME/.gnupg/secring.gpg by default')
  parser.add_argument('--rc-num', metavar='NUM', type=int, default=1,
                      help='Release Candidate number.  Default: 1')
  parser.add_argument('--root', metavar='PATH', default='.',
                      help='Root of Git working tree for lucene.  Default: "." (the current directory)')
  parser.add_argument('--logfile', metavar='PATH',
                      help='Specify log file path (default /tmp/release.log)')
  parser.add_argument('--dev-mode', default=False, action='store_true',
                      help='Enable development mode, which disables some strict checks')
  config = parser.parse_args()

  if not config.prepare and config.sign:
    parser.error('Cannot sign already built release')
  if config.push_local is not None and os.path.exists(config.push_local):
    parser.error('Cannot push to local path that already exists')
  if config.rc_num <= 0:
    parser.error('Release Candidate number must be a positive integer')
  if not os.path.isdir(config.root):
    parser.error('Root path "%s" is not a directory' % config.root)
  if config.local_keys is not None and not os.path.exists(config.local_keys):
    parser.error('Local KEYS file "%s" not found' % config.local_keys)
  if config.gpg_home and not os.path.exists(os.path.join(config.gpg_home, 'secring.gpg')):
    parser.error('Specified gpg home %s does not exist or does not contain a secring.gpg' % config.gpg_home)
  global dev_mode
  if config.dev_mode:
    print("Enabling development mode - DO NOT USE FOR ACTUAL RELEASE!")
    dev_mode = True
  cwd = os.getcwd()
  os.chdir(config.root)
  config.root = os.getcwd() # Absolutize root dir
  if os.system('git rev-parse') or 2 != len([d for d in ('dev-tools','lucene') if os.path.isdir(d)]):
    parser.error('Root path "%s" is not a valid lucene checkout' % config.root)
  os.chdir(cwd)
  global LOG
  if config.logfile:
    LOG = config.logfile
  print("Logfile is: %s" % LOG)

  config.version = read_version(config.root)
  print('Building version: %s' % config.version)

  return config

def check_cmdline_tools():  # Fail fast if there are cmdline tool problems
  if os.system('git --version >/dev/null 2>/dev/null'):
    raise RuntimeError('"git --version" returned a non-zero exit code.')

def check_key_in_keys(gpgKeyID, local_keys):
  if gpgKeyID is not None:
    print('  Verify your gpg key is in the main KEYS file')
    if local_keys is not None:
      print("    Using local KEYS file %s" % local_keys)
      keysFileText = open(local_keys, encoding='iso-8859-1').read()
      keysFileLocation = local_keys
    else:
      keysFileURL = "https://archive.apache.org/dist/lucene/KEYS"
      keysFileLocation = keysFileURL
      print("    Using online KEYS file %s" % keysFileURL)
      keysFileText = load(keysFileURL, encoding='iso-8859-1')
    if len(gpgKeyID) > 2 and gpgKeyID[0:2] == '0x':
      gpgKeyID = gpgKeyID[2:]
    if len(gpgKeyID) > 40:
      gpgKeyID = gpgKeyID.replace(" ", "")
    if len(gpgKeyID) == 8:
      gpgKeyID8Char = "%s %s" % (gpgKeyID[0:4], gpgKeyID[4:8])
      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)
    elif len(gpgKeyID) == 40:
      gpgKeyID40Char = "%s %s %s %s %s  %s %s %s %s %s" % \
                       (gpgKeyID[0:4], gpgKeyID[4:8], gpgKeyID[8:12], gpgKeyID[12:16], gpgKeyID[16:20],
                       gpgKeyID[20:24], gpgKeyID[24:28], gpgKeyID[28:32], gpgKeyID[32:36], gpgKeyID[36:])
      re_to_match = r"^pub .*\n\s+(%s|%s)" % (gpgKeyID40Char, gpgKeyID)
    else:
      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)
      exit(2)
    if re.search(re_to_match, keysFileText, re.MULTILINE):
      print('    Found key %s in KEYS file at %s' % (gpgKeyID, keysFileLocation))
    else:
      print('    ERROR: Did not find your key %s in KEYS file at %s. Please add it and try again.' % (gpgKeyID, keysFileLocation))
      if local_keys is not None:
        print('           You are using a local KEYS file. Make sure it is up to date or validate against the online version')
      exit(2)


def resolve_gpghome():
  for p in [
    # Linux, macos
    os.path.join(os.path.expanduser("~"), '.gnupg'),
    # Windows 10
    os.path.expandvars(r'%APPDATA%\GnuPG')
    # TODO: Should we support Cygwin?
  ]:
    if os.path.exists(os.path.join(p, 'secring.gpg')):
      return p
  return None


def main():
  check_cmdline_tools()

  c = parse_config()
  gpg_home = None

  if c.sign:
    sys.stdout.flush()
    c.key_id = c.sign
    check_key_in_keys(c.key_id, c.local_keys)
    if c.gpg_home is not None:
      print("Using custom gpg-home: %s" % c.gpg_home)
      gpg_home = c.gpg_home
    if c.sign_method_gradle:
      if gpg_home is None:
        resolved_gpg_home = resolve_gpghome()
        if resolved_gpg_home is not None:
          print("Resolved gpg home to %s" % resolved_gpg_home)
          gpg_home = resolved_gpg_home
        else:
          print("WARN: Could not locate your gpg secret keyring, and --gpg-home not specified.")
          print("      Falling back to location configured in gradle.properties.")
          print("      See 'gradlew helpPublishing' for details.")
          gpg_home = None
    if c.gpg_pass_noprompt:
      print("Will not prompt for gpg password. Make sure your signing setup supports this.")
      c.key_password = None
    else:
      import getpass
      c.key_password = getpass.getpass('Enter GPG keystore password: ')
  else:
    c.key_id = None
    c.key_password = None

  if c.prepare:
    prepare(c.root, c.version, c.pause_before_sign, c.key_id, c.key_password, gpg_home=gpg_home, sign_gradle=c.sign_method_gradle)
  else:
    os.chdir(c.root)

  if c.push_local:
    url = pushLocal(c.version, c.root, c.rc_num, c.push_local)
  else:
    url = None

  if url is not None:
    print('  URL: %s' % url)
    print('Next run the smoker tester:')
    p = re.compile(".*/")
    m = p.match(sys.argv[0])
    if not c.sign:
      signed = "--not-signed"
    else:
      signed = ""
    print('%s -u %ssmokeTestRelease.py %s %s' % (sys.executable, m.group(), signed, url))

if __name__ == '__main__':
  try:
    main()
  except KeyboardInterrupt:
    print('Keyboard interrupt...exiting')