From: Andrew Tridgell Date: Tue, 28 Apr 2026 04:27:41 +0000 (+1000) Subject: packaging: new release script X-Git-Tag: v3.4.2~1 X-Git-Url: http://git.ipfire.org/gitweb/?a=commitdiff_plain;h=006ee327d6734598a69d8a18e19eece6521a24aa;p=thirdparty%2Frsync.git packaging: new release script --- diff --git a/packaging/release.py b/packaging/release.py new file mode 100755 index 00000000..dc9d1fce --- /dev/null +++ b/packaging/release.py @@ -0,0 +1,703 @@ +#!/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