#!/usr/bin/env python3 # Step-based release script for rsync. Each step is a separate invocation # selected by a --step-N-XX option, so the maintainer drives the release # manually one piece at a time. # # All persistent state and working files live in ../release/ (a sibling of # the rsync git checkout): # # ../release/rsync-ftp/ mirror of samba.org:/home/ftp/pub/rsync # ../release/rsync-html/ git checkout of rsync-web (the html site) # ../release/work/ scratch space for tarball / diff staging # ../release/release-state.json info shared between steps # # The rsync-patches archive is no longer maintained and has been dropped. # # Run "packaging/release.py --list" to see the step list. import os, sys, re, argparse, glob, shutil, json, signal, subprocess from datetime import datetime sys.path = ['packaging'] + sys.path from pkglib import ( warn, die, cmd_run, cmd_chk, cmd_txt, cmd_txt_chk, cmd_pipe, check_git_state, get_rsync_version, get_NEWS_version_info, get_protocol_versions, ) # ---------- Paths ---------- RELEASE_DIR = os.path.realpath('../release') FTP_DIR = os.path.join(RELEASE_DIR, 'rsync-ftp') HTML_DIR = os.path.join(RELEASE_DIR, 'rsync-html') WORK_DIR = os.path.join(RELEASE_DIR, 'work') STATE_FILE = os.path.join(RELEASE_DIR, 'release-state.json') # Local rsync-web checkout (sibling of rsync-git) is the source-of-truth for # the git-tracked html content. The maintainer pulls/commits/pushes there; # step-1-fetch just snapshots it into HTML_DIR for the release flow. HTML_SRC = os.path.realpath('../rsync-web') FTP_REMOTE_PATH = '/home/ftp/pub/rsync' HTML_REMOTE_PATH = '/home/httpd/html/rsync' # Files that ./configure + make produce and that the release tarball / diff # need to bundle alongside the git-tracked source. Mirrors the GENFILES # definition in Makefile.in (with rrsync.1{,.html} since we always configure # --with-rrsync in --step-4-build). GEN_FILES = [ 'configure.sh', 'aclocal.m4', 'config.h.in', 'rsync.1', 'rsync.1.html', 'rsync-ssl.1', 'rsync-ssl.1.html', 'rsyncd.conf.5', 'rsyncd.conf.5.html', 'rrsync.1', 'rrsync.1.html', ] # ---------- Step registry ---------- STEPS = [ ('step-1-fetch', 'mirror ../release/rsync-ftp from samba.org and snapshot ../release/rsync-html from ../rsync-web'), ('step-2-prepare', 'gather release info interactively and write release-state.json'), ('step-3-tweak', 'update version.h, rsync.h, NEWS.md, and packaging/*.spec'), ('step-4-build', 'run smart-make + make gen'), ('step-5-commit', 'git commit -a (commit the prepared release changes)'), ('step-6-tag', 'create the gpg-signed git tag'), ('step-7-tarball', 'build the source tarball and diffs.gz against the previous release'), ('step-8-update-ftp', 'refresh README/NEWS/INSTALL/html in the ftp dir, regen ChangeLog.gz, gpg-sign tarballs'), ('step-9-toplinks', 'hard-link top-level release files (final releases only)'), ('step-10-push-ftp', 'rsync ../release/rsync-ftp/ to samba.org'), ('step-11-push-html', 'rsync ../release/rsync-html/ to samba.org (after any manual edits)'), ('step-12-push-git', 'print the git push commands for you to run'), ] STEP_FLAGS = [s[0] for s in STEPS] DASH_LINE = '=' * 74 # ---------- State helpers ---------- def load_state(): if not os.path.isfile(STATE_FILE): die(f"{STATE_FILE} not found. Run --step-2-prepare first.") with open(STATE_FILE, 'r', encoding='utf-8') as fh: return json.load(fh) def save_state(state): os.makedirs(RELEASE_DIR, exist_ok=True) with open(STATE_FILE, 'w', encoding='utf-8') as fh: json.dump(state, fh, indent=2, sort_keys=True) fh.write('\n') def require_samba_host(): host = os.environ.get('RSYNC_SAMBA_HOST', '') if not host.endswith('.samba.org'): die("Set RSYNC_SAMBA_HOST in your environment to the samba hostname (e.g. hr3.samba.org).") return host def require_top_of_checkout(): if not os.path.isfile('packaging/release.py'): die("Run this script from the top of your rsync checkout.") if not os.path.isdir('.git'): die("There is no .git dir in the current directory.") def replace_or_die(regex, repl, txt, die_msg): m = regex.search(txt) if not m: die(die_msg) return regex.sub(repl, txt, 1) def section(title): print(f"\n{DASH_LINE}\n== {title}\n{DASH_LINE}") def confirm(prompt, default_no=True): suffix = '[n] ' if default_no else '[y] ' ans = input(f"{prompt} {suffix}").strip().lower() if default_no: return ans.startswith('y') return ans == '' or ans.startswith('y') # ---------- Step 1: fetch ftp + html ---------- def step_1_fetch(args): host = require_samba_host() os.makedirs(RELEASE_DIR, exist_ok=True) os.makedirs(WORK_DIR, exist_ok=True) section(f"Fetching ftp dir into {FTP_DIR}") if not os.path.isdir(FTP_DIR): os.makedirs(FTP_DIR) # The .filt file lives in the ftp dir on the server; mirror down using the # transmitted filter, falling back to no filter on the very first pull. filt = os.path.join(FTP_DIR, '.filt') if os.path.exists(filt): opts = ['-aivOHP', f'-f:_{filt}'] else: opts = ['-aivOHP'] cmd_chk(['rsync', *opts, f'{host}:{FTP_REMOTE_PATH}/', f'{FTP_DIR}/']) section(f"Snapshotting html dir from {HTML_SRC} into {HTML_DIR}") if not os.path.isdir(HTML_SRC): die(f"{HTML_SRC} not found. Clone the rsync-web repo there first.") if not os.path.isdir(os.path.join(HTML_SRC, '.git')): die(f"{HTML_SRC} exists but is not a git checkout.") print(f"(Make sure {HTML_SRC} is up to date — this script does not 'git pull' for you.)") os.makedirs(HTML_DIR, exist_ok=True) cmd_chk(['rsync', '-aiv', '--exclude=/.git', f'{HTML_SRC}/', f'{HTML_DIR}/']) # Then mirror non-git html content from the server (mirroring samba-rsync's # behavior: skip files that the html git already provides). filt = os.path.join(HTML_DIR, 'filt') if os.path.exists(filt): tmp_filt = os.path.join(HTML_DIR, 'tmp-filt') cmd_chk(f"sed -n -e 's/[-P]/H/p' '{filt}' >'{tmp_filt}'") cmd_chk(['rsync', '-aivOHP', f'-f._{tmp_filt}', f'{host}:{HTML_REMOTE_PATH}/', f'{HTML_DIR}/']) os.unlink(tmp_filt) print(f"\nFetch complete. Local dirs are now in {RELEASE_DIR}.") # ---------- Step 2: prepare ---------- def step_2_prepare(args): require_top_of_checkout() os.makedirs(RELEASE_DIR, exist_ok=True) if not os.path.isdir(FTP_DIR): die(f"{FTP_DIR} does not exist. Run --step-1-fetch first.") now = datetime.now().astimezone() cl_today = now.strftime('* %a %b %d %Y') year = now.strftime('%Y') ztoday = now.strftime('%d %b %Y') today = ztoday.lstrip('0') tz_now = now.strftime('%z') tz_num = tz_now[0:1].replace('+', '') + str(float(tz_now[1:3]) + float(tz_now[3:]) / 60) curversion = get_rsync_version() lastversion, last_protocol_version, pdate = get_NEWS_version_info() protocol_version, subprotocol_version = get_protocol_versions() # Default next version: bump preN, or move dev -> pre1. version = curversion m = re.search(r'pre(\d+)', version) if m: version = re.sub(r'pre\d+', 'pre' + str(int(m[1]) + 1), version) else: version = version.replace('dev', 'pre1') print(f"\nCurrent version (version.h): {curversion}") print(f"Last released version (NEWS.md): {lastversion}") print(f"Current protocol version: {protocol_version} (last released: {last_protocol_version})") ans = input(f"\nVersion to release [{version}, '.' to drop the preN suffix]: ").strip() if ans == '.': version = re.sub(r'pre\d+', '', version) elif ans: version = ans if not re.match(r'^[\d.]+(pre\d+)?$', version): die(f'Invalid version: "{version}"') version = re.sub(r'[-.]*pre[-.]*', 'pre', version) if 'pre' in version and not curversion.endswith('dev'): lastversion = curversion ans = input(f"Previous version to diff against [{lastversion}]: ").strip() if ans: lastversion = ans lastversion = re.sub(r'[-.]*pre[-.]*', 'pre', lastversion) m = re.search(r'(pre\d+)', version) pre = m[1] if m else '' finalversion = re.sub(r'pre\d+', '', version) release = '0.1' if pre else '1' ans = input(f"RPM release number [{release}]: ").strip() if ans: release = ans if pre: release += '.' + pre proto_changed = protocol_version != last_protocol_version if proto_changed: if finalversion in pdate: proto_change_date = pdate[finalversion] else: while True: ans = input(f"Date the protocol changed to {protocol_version} (dd Mmm yyyy): ").strip() if re.match(r'^\d\d \w\w\w \d\d\d\d$', ans): break proto_change_date = ans else: proto_change_date = ' ' * 11 if 'pre' in lastversion: if not pre: die("Refusing to diff a release version against a pre-release version.") srcdir = srcdiffdir = lastsrcdir = 'src-previews' elif pre: srcdir = srcdiffdir = 'src-previews' lastsrcdir = 'src' else: srcdir = lastsrcdir = 'src' srcdiffdir = 'src-diffs' state = { 'version': version, 'lastversion': lastversion, 'finalversion': finalversion, 'pre': pre, 'release': release, 'protocol_version': protocol_version, 'subprotocol_version': subprotocol_version, 'proto_changed': proto_changed, 'proto_change_date': proto_change_date, 'srcdir': srcdir, 'srcdiffdir': srcdiffdir, 'lastsrcdir': lastsrcdir, 'today': today, 'ztoday': ztoday, 'cl_today': cl_today, 'year': year, 'tz_num': tz_num, 'master_branch': args.master_branch, } save_state(state) section("Release info") for k in ('version', 'lastversion', 'release', 'srcdir', 'srcdiffdir', 'lastsrcdir', 'protocol_version', 'proto_changed', 'proto_change_date'): print(f" {k}: {state[k]}") print(f"\nWrote {STATE_FILE}. Re-run --step-2-prepare to change anything.") # ---------- Step 3: tweak version files ---------- def step_3_tweak(args): require_top_of_checkout() state = load_state() version = state['version'] finalversion = state['finalversion'] pre = state['pre'] release = state['release'] today = state['today'] ztoday = state['ztoday'] cl_today = state['cl_today'] year = state['year'] tz_num = state['tz_num'] proto_changed = state['proto_changed'] proto_change_date = state['proto_change_date'] protocol_version = state['protocol_version'] srcdir = state['srcdir'] specvars = { 'Version:': finalversion, 'Release:': release, '%define fullversion': f'%{{version}}{pre}', 'Released': version + '.', '%define srcdir': srcdir, } tweak_files = ['version.h', 'rsync.h', 'NEWS.md'] tweak_files += glob.glob('packaging/*.spec') tweak_files += glob.glob('packaging/*/*.spec') for fn in tweak_files: with open(fn, 'r', encoding='utf-8') as fh: old_txt = txt = fh.read() if fn == 'version.h': x_re = re.compile(r'^(#define RSYNC_VERSION).*', re.M) txt = replace_or_die(x_re, r'\1 "%s"' % version, txt, f"Unable to update RSYNC_VERSION in {fn}") x_re = re.compile(r'^(#define MAINTAINER_TZ_OFFSET).*', re.M) txt = replace_or_die(x_re, r'\1 ' + tz_num, txt, f"Unable to update MAINTAINER_TZ_OFFSET in {fn}") elif fn == 'rsync.h': x_re = re.compile(r'(#define\s+SUBPROTOCOL_VERSION)\s+(\d+)') repl = lambda m: m[1] + ' ' + ( '0' if not pre or not proto_changed else '1' if m[2] == '0' else m[2]) txt = replace_or_die(x_re, repl, txt, f"Unable to find SUBPROTOCOL_VERSION in {fn}") elif fn == 'NEWS.md': efv = re.escape(finalversion) x_re = re.compile( r'^# NEWS for rsync %s \(UNRELEASED\)\s+## Changes in this version:\n' % efv + r'(\n### PROTOCOL NUMBER:\s+- The protocol number was changed to \d+\.\n)?') rel_day = 'UNRELEASED' if pre else today repl = (f'# NEWS for rsync {finalversion} ({rel_day})\n\n' + '## Changes in this version:\n') if proto_changed: repl += f'\n### PROTOCOL NUMBER:\n\n - The protocol number was changed to {protocol_version}.\n' good_top = re.sub(r'\(.*?\)', '(UNRELEASED)', repl, 1) msg = (f"The top of {fn} is not in the right format. It should be:\n" + good_top) txt = replace_or_die(x_re, repl, txt, msg) x_re = re.compile( r'^(\| )(\S{2} \S{3} \d{4})(\s+\|\s+%s\s+\| ).{11}(\s+\| )\S{2}(\s+\|+)$' % efv, re.M) repl = lambda m: (m[1] + (m[2] if pre else ztoday) + m[3] + proto_change_date + m[4] + protocol_version + m[5]) txt = replace_or_die(x_re, repl, txt, f'Unable to find "| ?? ??? {year} | {finalversion} | ... |" line in {fn}') elif '.spec' in fn: for var, val in specvars.items(): x_re = re.compile(r'^%s .*' % re.escape(var), re.M) txt = replace_or_die(x_re, var + ' ' + val, txt, f"Unable to update {var} in {fn}") x_re = re.compile(r'^\* \w\w\w \w\w\w \d\d \d\d\d\d (.*)', re.M) txt = replace_or_die(x_re, r'%s \1' % cl_today, txt, f"Unable to update ChangeLog header in {fn}") else: die(f"Unrecognized file in tweak_files: {fn}") if txt != old_txt: print(f"Updating {fn}") with open(fn, 'w', encoding='utf-8') as fh: fh.write(txt) cmd_chk(['packaging/year-tweak']) section("git diff after tweaks") cmd_run(['git', '--no-pager', 'diff']) # ---------- Step 4: build ---------- def step_4_build(args): require_top_of_checkout() load_state() # just to ensure we've prepared section("Running prepare-source + configure --prefix=/usr --with-rrsync + make + make gen") # Always re-prepare so configure.sh is current; we run configure ourselves # with the release-required flags rather than relying on the cached # config.status (which may have been produced with different options). if os.path.isfile('.fetch'): cmd_chk(['./prepare-source', 'fetch']) else: cmd_chk(['./prepare-source']) cmd_chk(['./configure', '--prefix=/usr', '--with-rrsync']) cmd_chk(['make']) cmd_chk(['make', 'gen']) # ---------- Step 5: commit ---------- def step_5_commit(args): require_top_of_checkout() state = load_state() version = state['version'] section("git status") cmd_run(['git', 'status']) if not confirm("Commit all current changes with the release message?"): die("Aborted.") cmd_chk(['git', 'commit', '-a', '-m', f'Preparing for release of {version} [buildall]']) # ---------- Step 6: tag ---------- def step_6_tag(args): require_top_of_checkout() state = load_state() version = state['version'] v_ver = 'v' + version out = cmd_txt_chk(['git', 'tag', '-l', v_ver]).out if out.strip(): if not confirm(f"Tag {v_ver} already exists. Delete and recreate?"): die("Aborted.") cmd_chk(['git', 'tag', '-d', v_ver]) # Prime the gpg agent so the actual tag signing won't prompt. section("Priming gpg agent") cmd_run("touch TeMp; gpg --sign TeMp; rm -f TeMp TeMp.gpg") section(f"Creating signed tag {v_ver}") out = cmd_txt(['git', 'tag', '-s', '-m', f'Version {version}.', v_ver], capture='combined').out print(out, end='') if 'bad passphrase' in out.lower() or 'failed' in out.lower(): die("Tag creation failed.") # ---------- Step 7: tarball + diff ---------- def step_7_tarball(args): require_top_of_checkout() state = load_state() version = state['version'] lastversion = state['lastversion'] pre = state['pre'] srcdir = state['srcdir'] srcdiffdir = state['srcdiffdir'] lastsrcdir = state['lastsrcdir'] rsync_ver = 'rsync-' + version rsync_lastver = 'rsync-' + lastversion v_ver = 'v' + version srctar_name = f"{rsync_ver}.tar.gz" diff_name = f"{rsync_lastver}-{version}.diffs.gz" srctar_file = os.path.join(FTP_DIR, srcdir, srctar_name) diff_file = os.path.join(FTP_DIR, srcdiffdir, diff_name) lasttar_file = os.path.join(FTP_DIR, lastsrcdir, rsync_lastver + '.tar.gz') for d in (os.path.dirname(srctar_file), os.path.dirname(diff_file)): os.makedirs(d, exist_ok=True) if not os.path.isfile(lasttar_file): die(f"Previous tarball not found: {lasttar_file}") # Stage in ../release/work to keep the source checkout clean. if os.path.isdir(WORK_DIR): shutil.rmtree(WORK_DIR) os.makedirs(WORK_DIR) a_dir = os.path.join(WORK_DIR, 'a') b_dir = os.path.join(WORK_DIR, 'b') # Extract gen files from the previous tarball into work/a/. tweaked_gen_files = [os.path.join(rsync_lastver, fn) for fn in GEN_FILES] cmd_chk(['tar', '-C', WORK_DIR, '-xzf', lasttar_file, *tweaked_gen_files]) os.rename(os.path.join(WORK_DIR, rsync_lastver), a_dir) # Copy current gen files (built in the top-level checkout) into work/b/. os.makedirs(b_dir) cmd_chk(['rsync', '-a', *GEN_FILES, b_dir + '/']) section(f"Creating {diff_file}") sed_script = r's:^((---|\+\+\+) [ab]/[^\t]+)\t.*:\1:' # no single quotes! cmd_chk( f"(git diff v{lastversion} {v_ver} -- ':!.github'; " f"diff -upN {a_dir} {b_dir} | sed -r '{sed_script}') | gzip -9 >{diff_file}") section(f"Creating {srctar_file}") # Reuse work/b/ (which already holds the fresh gen files) as the release # staging dir, then let "git archive" overlay the git-tracked source files # on top. That way the tarball ends up with both gen files and source. rsync_ver_dir = os.path.join(WORK_DIR, rsync_ver) shutil.rmtree(a_dir) os.rename(b_dir, rsync_ver_dir) cmd_chk(f"git archive --format=tar --prefix={rsync_ver}/ {v_ver} | " f"tar -C {WORK_DIR} -xf -") cmd_chk(f"support/git-set-file-times --quiet --prefix={rsync_ver_dir}/") cmd_chk(['fakeroot', 'tar', '-C', WORK_DIR, '-czf', srctar_file, '--exclude=.github', rsync_ver]) # Leave staging in place; --step-8-update-ftp does its own thing. print(f"\nCreated:\n {srctar_file}\n {diff_file}") # ---------- Step 8: update ftp ---------- def step_8_update_ftp(args): require_top_of_checkout() state = load_state() version = state['version'] lastversion = state['lastversion'] srcdir = state['srcdir'] srcdiffdir = state['srcdiffdir'] rsync_ver = 'rsync-' + version rsync_lastver = 'rsync-' + lastversion srctar_file = os.path.join(FTP_DIR, srcdir, f"{rsync_ver}.tar.gz") diff_file = os.path.join(FTP_DIR, srcdiffdir, f"{rsync_lastver}-{version}.diffs.gz") section(f"Refreshing top-of-tree files in {FTP_DIR}") md_files = ['README.md', 'NEWS.md', 'INSTALL.md'] html_files = [fn for fn in GEN_FILES if fn.endswith('.html')] cmd_chk(['rsync', '-a', *md_files, *html_files, FTP_DIR + '/']) cmd_chk(['./md-convert', '--dest', FTP_DIR, *md_files]) section(f"Regenerating {FTP_DIR}/ChangeLog.gz") cmd_chk(f"git log --name-status | gzip -9 >{FTP_DIR}/ChangeLog.gz") # Prime gpg agent and then sign the tar + diff. section("Priming gpg agent") cmd_run("touch TeMp; gpg --sign TeMp; rm -f TeMp TeMp.gpg") for fn in (srctar_file, diff_file): if not os.path.isfile(fn): die(f"Missing file to sign: {fn}. Did --step-7-tarball run successfully?") asc_fn = fn + '.asc' if os.path.lexists(asc_fn): os.unlink(asc_fn) section(f"GPG-signing {fn}") res = cmd_run(['gpg', '--batch', '-ba', fn]) if res.returncode not in (0, 2): die("gpg signing failed.") # ---------- Step 9: top-level hard links ---------- def step_9_toplinks(args): require_top_of_checkout() state = load_state() pre = state['pre'] if pre: print("Skipping: pre-releases do not get top-level hard links.") return version = state['version'] lastversion = state['lastversion'] srcdir = state['srcdir'] srcdiffdir = state['srcdiffdir'] rsync_ver = 'rsync-' + version rsync_lastver = 'rsync-' + lastversion srctar_file = os.path.join(FTP_DIR, srcdir, f"{rsync_ver}.tar.gz") diff_file = os.path.join(FTP_DIR, srcdiffdir, f"{rsync_lastver}-{version}.diffs.gz") section("Removing stale top-level rsync-* files") for find in [f'{FTP_DIR}/rsync-*.gz', f'{FTP_DIR}/rsync-*.asc', f'{FTP_DIR}/src-previews/rsync-*diffs.gz*']: for fn in glob.glob(find): os.unlink(fn) top_link = [ srctar_file, srctar_file + '.asc', diff_file, diff_file + '.asc', ] for fn in top_link: target = re.sub(r'/src(-\w+)?/', '/', fn) if os.path.lexists(target): os.unlink(target) os.link(fn, target) print(f" linked {target}") # ---------- Step 10: push ftp ---------- def step_10_push_ftp(args): host = require_samba_host() if not os.path.isdir(FTP_DIR): die(f"{FTP_DIR} does not exist. Run --step-1-fetch first.") section(f"rsync ftp dir to {host}") rsync_with_confirm(['-aivOHP', '--chown=:rsync', '--del', f'-f._{os.path.join(FTP_DIR, ".filt")}', f'{FTP_DIR}/', f'{host}:{FTP_REMOTE_PATH}/']) # ---------- Step 11: push html ---------- def step_11_push_html(args): host = require_samba_host() if not os.path.isdir(HTML_DIR): die(f"{HTML_DIR} does not exist. Run --step-1-fetch first.") section(f"rsync html dir to {host}") filt = os.path.join(HTML_DIR, 'filt') rsync_with_confirm(['-aivOHP', '--chown=:rsync', '--del', f'-f._{filt}', f'{HTML_DIR}/', f'{host}:{HTML_REMOTE_PATH}/']) # ---------- Step 12: print push-git instructions ---------- def step_12_push_git(args): state = load_state() version = state['version'] master_branch = state['master_branch'] v_ver = 'v' + version print(f"""\ {DASH_LINE} Run these from the rsync-git checkout (this script does not push for you): git push origin {master_branch} git push origin {v_ver} If you have a 'samba' remote configured (git.samba.org:/data/git/rsync.git): git push samba {master_branch} git push samba {v_ver} Then upload the tarball + .asc to the GitHub release for {v_ver}, run packaging/send-news (when convenient), and announce on rsync-announce@, rsync@, and Discord. """) # ---------- shared rsync-with-confirm ---------- def rsync_with_confirm(rsync_args): """Run an rsync command in dry-run mode, then ask before running for real.""" cmd_run(['rsync', '--dry-run', *rsync_args]) if confirm("Run without --dry-run?"): cmd_run(['rsync', *rsync_args]) # ---------- dispatch ---------- STEP_FUNCS = { 'step-1-fetch': step_1_fetch, 'step-2-prepare': step_2_prepare, 'step-3-tweak': step_3_tweak, 'step-4-build': step_4_build, 'step-5-commit': step_5_commit, 'step-6-tag': step_6_tag, 'step-7-tarball': step_7_tarball, 'step-8-update-ftp': step_8_update_ftp, 'step-9-toplinks': step_9_toplinks, 'step-10-push-ftp': step_10_push_ftp, 'step-11-push-html': step_11_push_html, 'step-12-push-git': step_12_push_git, } def signal_handler(sig, frame): die("\nAborting due to SIGINT.") def main(): parser = argparse.ArgumentParser( description="Step-based release script for rsync.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog="Run --list to see the steps. Each invocation runs exactly one --step-* option.") parser.add_argument('--branch', '-b', dest='master_branch', default='master', help="The branch to release (default: master).") parser.add_argument('--list', action='store_true', help="List all release steps and exit.") grp = parser.add_mutually_exclusive_group() for flag, descr in STEPS: grp.add_argument('--' + flag, dest='step', action='store_const', const=flag, help=descr) args = parser.parse_args() if args.list: print("Release steps:") for flag, descr in STEPS: print(f" --{flag:18s} {descr}") return if not args.step: parser.error("pick one --step-N-XX option (or --list to see them).") signal.signal(signal.SIGINT, signal_handler) os.environ['LESS'] = 'mqeiXR' STEP_FUNCS[args.step](args) if __name__ == '__main__': main() # vim: sw=4 et ft=python