#!/usr/bin/env python # python: 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. # # About this script: # This script is intended to simplify creating Subversion releases for # any of the supported release lines of Subversion. # It works well with our Apache infrastructure, and should make rolling, # posting, and announcing releases dirt simple. # # This script may be run on a number of platforms, but it is intended to # be run on people.apache.org. As such, it may have dependencies (such # as Python version) which may not be common, but are guaranteed to be # available on people.apache.org. # It'd be kind of nice to use the Subversion python bindings in this script, # but people.apache.org doesn't currently have them installed # Stuff we need import os import re import sys import glob import fnmatch import shutil try: from urllib.request import urlopen # Python 3 except: from urllib2 import urlopen # Python 2 import hashlib import tarfile import logging import datetime import tempfile import operator import itertools import subprocess import argparse # standard in Python 2.7 import io import yaml import backport.status # Find ezt, using Subversion's copy, if there isn't one on the system. try: import ezt except ImportError: ezt_path = os.path.dirname(os.path.dirname(os.path.abspath(sys.path[0]))) ezt_path = os.path.join(ezt_path, 'build', 'generator') sys.path.append(ezt_path) import ezt sys.path.remove(ezt_path) def get_dist_metadata_file_path(): return os.path.join(os.path.abspath(sys.path[0]), 'release-lines.yaml') # Read the dist metadata (about release lines) with open(get_dist_metadata_file_path(), 'r') as stream: dist_metadata = yaml.safe_load(stream) # Our required / recommended release tool versions by release branch tool_versions = dist_metadata['tool_versions'] # The version that is our current recommended release recommended_release = dist_metadata['recommended_release'] # For clean-dist, a whitelist of artifacts to keep, by version. supported_release_lines = frozenset(dist_metadata['supported_release_lines']) # Some constants svn_repos = os.getenv('SVN_RELEASE_SVN_REPOS', 'https://svn.apache.org/repos/asf/subversion') dist_repos = os.getenv('SVN_RELEASE_DIST_REPOS', 'https://dist.apache.org/repos/dist') dist_dev_url = dist_repos + '/dev/subversion' dist_release_url = dist_repos + '/release/subversion' dist_archive_url = 'https://archive.apache.org/dist/subversion' extns = ['zip', 'tar.gz', 'tar.bz2'] #---------------------------------------------------------------------- # Utility functions class Version(object): regex = re.compile(r'(\d+).(\d+).(\d+)(?:-(?:(rc|alpha|beta)(\d+)))?') def __init__(self, ver_str): # Special case the 'trunk-nightly' version if ver_str == 'trunk-nightly': self.major = None self.minor = None self.patch = None self.pre = 'nightly' self.pre_num = None self.base = 'nightly' self.branch = 'trunk' return match = self.regex.search(ver_str) if not match: raise RuntimeError("Bad version string '%s'" % ver_str) self.major = int(match.group(1)) self.minor = int(match.group(2)) self.patch = int(match.group(3)) if match.group(4): self.pre = match.group(4) self.pre_num = int(match.group(5)) else: self.pre = None self.pre_num = None self.base = '%d.%d.%d' % (self.major, self.minor, self.patch) self.branch = '%d.%d' % (self.major, self.minor) def is_prerelease(self): return self.pre != None def get_ver_tags(self, revnum): # These get substituted into svn_version.h ver_tag = '' ver_numtag = '' if self.pre == 'alpha': ver_tag = '" (Alpha %d)"' % self.pre_num ver_numtag = '"-alpha%d"' % self.pre_num elif self.pre == 'beta': ver_tag = '" (Beta %d)"' % self.pre_num ver_numtag = '"-beta%d"' % self.pre_num elif self.pre == 'rc': ver_tag = '" (Release Candidate %d)"' % self.pre_num ver_numtag = '"-rc%d"' % self.pre_num elif self.pre == 'nightly': ver_tag = '" (Nightly Build r%d)"' % revnum ver_numtag = '"-nightly-r%d"' % revnum else: ver_tag = '" (r%d)"' % revnum ver_numtag = '""' return (ver_tag, ver_numtag) def __serialize(self): return (self.major, self.minor, self.patch, self.pre, self.pre_num) def __eq__(self, that): return self.__serialize() == that.__serialize() def __ne__(self, that): return self.__serialize() != that.__serialize() def __hash__(self): return hash(self.__serialize()) def __lt__(self, that): if self.major < that.major: return True if self.major > that.major: return False if self.minor < that.minor: return True if self.minor > that.minor: return False if self.patch < that.patch: return True if self.patch > that.patch: return False if not self.pre and not that.pre: return False if not self.pre and that.pre: return False if self.pre and not that.pre: return True # We are both pre-releases if self.pre != that.pre: return self.pre < that.pre else: return self.pre_num < that.pre_num def __str__(self): "Return an SVN_VER_NUMBER-formatted string, or 'nightly'." if self.pre: if self.pre == 'nightly': return 'nightly' else: extra = '-%s%d' % (self.pre, self.pre_num) else: extra = '' return self.base + extra def __repr__(self): return "Version(%s)" % repr(str(self)) def get_prefix(base_dir): return os.path.join(base_dir, 'prefix') def get_tempdir(base_dir): return os.path.join(base_dir, 'tempdir') def get_workdir(base_dir): return os.path.join(get_tempdir(base_dir), 'working') # The name of this directory is also used to name the tarball and for # the root of paths within the tarball, e.g. subversion-1.9.5 or # subversion-nightly-r1800000 def get_exportdir(base_dir, version, revnum): if version.pre != 'nightly': return os.path.join(get_tempdir(base_dir), 'subversion-'+str(version)) return os.path.join(get_tempdir(base_dir), 'subversion-%s-r%d' % (version, revnum)) def get_target(args): "Return the location of the artifacts" if args.target: return args.target else: return os.path.join(args.base_dir, 'deploy') def get_branch_path(args): if not args.branch: try: args.branch = 'branches/%d.%d.x' % (args.version.major, args.version.minor) except AttributeError: raise RuntimeError("Please specify the branch using the release version label argument (for certain subcommands) or the '--branch' global option") return args.branch.rstrip('/') # canonicalize for later comparisons def get_tmpldir(): return os.path.join(os.path.abspath(sys.path[0]), 'templates') def get_tmplfile(filename): try: return open(os.path.join(get_tmpldir(), filename)) except IOError: # Hmm, we had a problem with the local version, let's try the repo return urlopen(svn_repos + '/trunk/tools/dist/templates/' + filename) def get_nullfile(): return open(os.path.devnull, 'w') def run_command(cmd, verbose=True, hide_stderr=False, dry_run=False): if verbose: print("+ " + ' '.join(cmd)) stderr = None if verbose: stdout = None else: stdout = get_nullfile() if hide_stderr: stderr = get_nullfile() if not dry_run: subprocess.check_call(cmd, stdout=stdout, stderr=stderr) else: print(' ## dry-run; not executed') def run_script(verbose, script, hide_stderr=False): for l in script.split('\n'): run_command(l.split(), verbose, hide_stderr) def download_file(url, target, checksum): """Download the file at URL to the local path TARGET. If CHECKSUM is a string, verify the checksum of the downloaded file and raise RuntimeError if it does not match. If CHECKSUM is None, do not verify the downloaded file. """ assert checksum is None or isinstance(checksum, str) response = urlopen(url) target_file = open(target, 'w+b') target_file.write(response.read()) target_file.seek(0) m = hashlib.sha256() m.update(target_file.read()) target_file.close() checksum2 = m.hexdigest() if checksum is not None and checksum != checksum2: raise RuntimeError("Checksum mismatch for '%s': "\ "downloaded: '%s'; expected: '%s'" % \ (target, checksum, checksum2)) def run_svn(cmd, verbose=True, dry_run=False, username=None): if (username): cmd[:0] = ['--username', username] run_command(['svn'] + cmd, verbose=verbose, dry_run=dry_run) def run_svnmucc(cmd, verbose=True, dry_run=False, username=None): if (username): cmd[:0] = ['--username', username] run_command(['svnmucc'] + cmd, verbose=verbose, dry_run=dry_run) #---------------------------------------------------------------------- def is_recommended(version): return version.branch == recommended_release def get_download_anchor(version): if version.is_prerelease(): return 'pre-releases' else: if is_recommended(version): return 'recommended-release' else: return 'supported-releases' #---------------------------------------------------------------------- # ezt helpers # In ezt, «[if-any foo]» is true when «data['foo'] == False», # hence, provide this constant for readability. ezt_False = "" # And this constant for symmetry. ezt_True = True # And this for convenience. def ezt_bool(boolean_value): return ezt_True if boolean_value else ezt_False #---------------------------------------------------------------------- # Cleaning up the environment def cleanup(args): 'Remove generated files and folders.' logging.info('Cleaning') shutil.rmtree(get_prefix(args.base_dir), True) shutil.rmtree(get_tempdir(args.base_dir), True) shutil.rmtree(get_target(args), True) #---------------------------------------------------------------------- # Creating an environment to roll the release class RollDep(object): 'The super class for each of the build dependencies.' def __init__(self, base_dir, use_existing, verbose): self._base_dir = base_dir self._use_existing = use_existing self._verbose = verbose def _test_version(self, cmd): proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True) (stdout, stderr) = proc.communicate() rc = proc.wait() if rc: return '' return stdout.split('\n') def build(self): if not hasattr(self, '_extra_configure_flags'): self._extra_configure_flags = '' cwd = os.getcwd() tempdir = get_tempdir(self._base_dir) tarball = os.path.join(tempdir, self._filebase + '.tar.gz') if os.path.exists(tarball): if not self._use_existing: raise RuntimeError('autoconf tarball "%s" already exists' % tarball) logging.info('Using existing %s.tar.gz' % self._filebase) else: logging.info('Fetching %s' % self._filebase) download_file(self._url, tarball, self._checksum) # Extract tarball tarfile.open(tarball).extractall(tempdir) logging.info('Building ' + self.label) os.chdir(os.path.join(tempdir, self._filebase)) run_script(self._verbose, '''./configure --prefix=%s %s make make install''' % (get_prefix(self._base_dir), self._extra_configure_flags)) os.chdir(cwd) class AutoconfDep(RollDep): def __init__(self, base_dir, use_existing, verbose, autoconf_ver, checksum): RollDep.__init__(self, base_dir, use_existing, verbose) self.label = 'autoconf' self._filebase = 'autoconf-' + autoconf_ver self._autoconf_ver = autoconf_ver self._url = 'https://ftp.gnu.org/gnu/autoconf/%s.tar.gz' % self._filebase self._checksum = checksum def have_usable(self): output = self._test_version(['autoconf', '-V']) if not output: return False version = output[0].split()[-1:][0] return version == self._autoconf_ver def use_system(self): if not self._use_existing: return False return self.have_usable() class LibtoolDep(RollDep): def __init__(self, base_dir, use_existing, verbose, libtool_ver, checksum): RollDep.__init__(self, base_dir, use_existing, verbose) self.label = 'libtool' self._filebase = 'libtool-' + libtool_ver self._libtool_ver = libtool_ver self._url = 'https://ftp.gnu.org/gnu/libtool/%s.tar.gz' % self._filebase self._checksum = checksum def have_usable(self): output = self._test_version(['libtool', '--version']) if not output: return False return self._libtool_ver in output[0] def use_system(self): # We unconditionally return False here, to avoid using a borked # system libtool (I'm looking at you, Debian). return False def build(self): RollDep.build(self) # autogen.sh looks for glibtoolize before libtoolize bin_dir = os.path.join(get_prefix(self._base_dir), "bin") os.symlink("libtoolize", os.path.join(bin_dir, "glibtoolize")) os.symlink("libtool", os.path.join(bin_dir, "glibtool")) class SwigDep(RollDep): def __init__(self, base_dir, use_existing, verbose, swig_ver, checksum, sf_mirror): RollDep.__init__(self, base_dir, use_existing, verbose) self.label = 'swig' self._filebase = 'swig-' + swig_ver self._swig_ver = swig_ver self._url = 'https://sourceforge.net/projects/swig/files/swig/%(swig)s/%(swig)s.tar.gz/download?use_mirror=%(sf_mirror)s' % \ { 'swig' : self._filebase, 'sf_mirror' : sf_mirror } self._checksum = checksum self._extra_configure_flags = '--without-pcre' def have_usable(self): output = self._test_version(['swig', '-version']) if not output: return False version = output[1].split()[-1:][0] return version == self._swig_ver def use_system(self): if not self._use_existing: return False return self.have_usable() def build_env(args): 'Download prerequisites for a release and prepare the environment.' logging.info('Creating release environment') try: os.mkdir(get_prefix(args.base_dir)) os.mkdir(get_tempdir(args.base_dir)) except OSError: if not args.use_existing: raise autoconf = AutoconfDep(args.base_dir, args.use_existing, args.verbose, tool_versions[args.version.branch]['autoconf'][0], tool_versions[args.version.branch]['autoconf'][1]) libtool = LibtoolDep(args.base_dir, args.use_existing, args.verbose, tool_versions[args.version.branch]['libtool'][0], tool_versions[args.version.branch]['libtool'][1]) swig = SwigDep(args.base_dir, args.use_existing, args.verbose, tool_versions[args.version.branch]['swig'][0], tool_versions[args.version.branch]['swig'][1], args.sf_mirror) # iterate over our rolling deps, and build them if needed for dep in [autoconf, libtool, swig]: if dep.use_system(): logging.info('Using system %s' % dep.label) else: dep.build() #---------------------------------------------------------------------- # Create a new minor release branch def get_trunk_wc_path(base_dir, path=None): trunk_wc_path = os.path.join(get_tempdir(base_dir), 'svn-trunk') if path is None: return trunk_wc_path return os.path.join(trunk_wc_path, path) def get_trunk_url(revnum=None): return svn_repos + '/trunk' + '@' + (str(revnum) if revnum else '') def get_branch_url(ver): return svn_repos + '/branches/' + ver.branch + '.x' def get_tag_url(ver): return svn_repos + '/tags/' + ver.base def edit_file(path, pattern, replacement): print("Editing '%s'" % (path,)) print(" pattern='%s'" % (pattern,)) print(" replace='%s'" % (replacement,)) old_text = open(path, 'r').read() new_text = re.sub(pattern, replacement, old_text) assert new_text != old_text open(path, 'w').write(new_text) def edit_changes_file(path, newtext): """Insert NEWTEXT in the 'CHANGES' file found at PATH, just before the first line that starts with 'Version '. """ print("Prepending to '%s'" % (path,)) print(" text='%s'" % (newtext,)) lines = open(path, 'r').readlines() for i, line in enumerate(lines): if line.startswith('Version '): with open(path, 'w') as newfile: newfile.writelines(lines[:i]) newfile.write(newtext) newfile.writelines(lines[i:]) break #---------------------------------------------------------------------- def make_release_branch(args): ver = args.version run_svn(['copy', get_trunk_url(args.revnum), get_branch_url(ver), '-m', 'Create the ' + ver.branch + '.x release branch.'], dry_run=args.dry_run) #---------------------------------------------------------------------- def update_minor_ver_in_trunk(args): """Change the minor version in trunk to the next (future) minor version. """ ver = args.version trunk_wc = get_trunk_wc_path(args.base_dir) run_svn(['checkout', get_trunk_url(args.revnum), trunk_wc]) prev_ver = Version('1.%d.0' % (ver.minor - 1,)) next_ver = Version('1.%d.0' % (ver.minor + 1,)) relpaths = [] relpath = 'subversion/include/svn_version.h' relpaths.append(relpath) edit_file(get_trunk_wc_path(args.base_dir, relpath), r'(#define SVN_VER_MINOR *)%s' % (ver.minor,), r'\g<1>%s' % (next_ver.minor,)) relpath = 'subversion/tests/cmdline/svntest/main.py' relpaths.append(relpath) edit_file(get_trunk_wc_path(args.base_dir, relpath), r'(SVN_VER_MINOR = )%s' % (ver.minor,), r'\g<1>%s' % (next_ver.minor,)) relpath = 'subversion/bindings/javahl/src/org/apache/subversion/javahl/NativeResources.java' relpaths.append(relpath) try: # since r1817921 (just after branching 1.10) edit_file(get_trunk_wc_path(args.base_dir, relpath), r'SVN_VER_MINOR = %s;' % (ver.minor,), r'SVN_VER_MINOR = %s;' % (next_ver.minor,)) except: # before r1817921: two separate places edit_file(get_trunk_wc_path(args.base_dir, relpath), r'version.isAtLeast\(1, %s, 0\)' % (ver.minor,), r'version.isAtLeast\(1, %s, 0\)' % (next_ver.minor,)) edit_file(get_trunk_wc_path(args.base_dir, relpath), r'1.%s.0, but' % (ver.minor,), r'1.%s.0, but' % (next_ver.minor,)) relpath = 'CHANGES' relpaths.append(relpath) # insert at beginning of CHANGES file edit_changes_file(get_trunk_wc_path(args.base_dir, relpath), 'Version ' + next_ver.base + '\n' + '(?? ??? 20XX, from /branches/' + next_ver.branch + '.x)\n' + get_tag_url(next_ver) + '\n' + '\n') log_msg = '''\ Increment the trunk version number to %s, and introduce a new CHANGES section, following the creation of the %s.x release branch. * subversion/include/svn_version.h, subversion/bindings/javahl/src/org/apache/subversion/javahl/NativeResources.java, subversion/tests/cmdline/svntest/main.py (SVN_VER_MINOR): Increment to %s. * CHANGES: New section for %s.0. ''' % (next_ver.branch, ver.branch, next_ver.minor, next_ver.branch) commit_paths = [get_trunk_wc_path(args.base_dir, p) for p in relpaths] run_svn(['commit'] + commit_paths + ['-m', log_msg], dry_run=args.dry_run) #---------------------------------------------------------------------- def create_status_file_on_branch(args): ver = args.version branch_wc = get_workdir(args.base_dir) branch_url = get_branch_url(ver) run_svn(['checkout', branch_url, branch_wc, '--depth=immediates'], dry_run=args.dry_run) status_local_path = os.path.join(branch_wc, 'STATUS') template_filename = 'STATUS.ezt' data = { 'major-minor' : ver.branch, 'major-minor-patch' : ver.base, } template = ezt.Template(compress_whitespace=False) template.parse(get_tmplfile(template_filename).read()) if args.dry_run: print('\nNew STATUS file:') print(template.generate(sys.stdout, data)) else: with open(status_local_path, 'x') as g: template.generate(g, data) run_svn(['add', status_local_path], dry_run=args.dry_run) run_svn(['commit', status_local_path, '-m', '* branches/' + ver.branch + '.x/STATUS: New file.'], dry_run=args.dry_run) #---------------------------------------------------------------------- def update_backport_bot(args): ver = args.version print("""\ *** MANUAL STEP REQUIRED *** Ask someone with appropriate access to add the %s.x branch to the backport merge bot. See https://subversion.apache.org/docs/community-guide/releasing.html#backport-merge-bot *** """ % (ver.branch,)) #---------------------------------------------------------------------- def create_release_branch(args): make_release_branch(args) update_minor_ver_in_trunk(args) create_status_file_on_branch(args) update_backport_bot(args) #---------------------------------------------------------------------- def write_release_notes(args): # Create a skeleton release notes file from template template_filename = 'release-notes.ezt' prev_ver = Version('%d.%d.0' % (args.version.major, args.version.minor - 1)) data = { 'major-minor' : args.version.branch, 'previous-major-minor' : prev_ver.branch, } template = ezt.Template(compress_whitespace=False) template.parse(get_tmplfile(template_filename).read()) if args.edit_html_file: with open(args.edit_html_file, 'w') as g: template.generate(g, data) else: template.generate(sys.stdout, data) # Add an "in progress" entry in the release notes index # index_file = os.path.normpath(args.edit_html_file + '/../index.html') marker = '