|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Build and upload userver source package to Launchpad PPA. |
| 3 | +
|
| 4 | +Orchestrates: |
| 5 | + 1. scripts/generate-debian-directory.sh (generates debian/ + vendors pydantic wheels) |
| 6 | + 2. debuild -S -sa (builds signed source package) |
| 7 | + 3. dput ppa:userver-framework/userver (uploads to Launchpad) |
| 8 | +
|
| 9 | +Usage: |
| 10 | + scripts/build-and-upload-ppa --distro ubuntu-24.04 --version-kind nightly |
| 11 | + scripts/build-and-upload-ppa --distro ubuntu-22.04 --version-kind release |
| 12 | +""" |
| 13 | + |
| 14 | +import argparse |
| 15 | +import datetime |
| 16 | +import os |
| 17 | +import pathlib |
| 18 | +import re |
| 19 | +import subprocess |
| 20 | +import sys |
| 21 | + |
| 22 | +PPA = 'ppa:userver-framework/userver' |
| 23 | + |
| 24 | +SUPPORTED_DISTROS = ['ubuntu-22.04', 'ubuntu-24.04'] |
| 25 | + |
| 26 | + |
| 27 | +def find_repo_root() -> pathlib.Path: |
| 28 | + root = pathlib.Path(__file__).resolve().parent.parent.parent |
| 29 | + missing = [p for p in ['version.txt', 'scripts/generate-debian-directory.sh'] if not (root / p).exists()] |
| 30 | + if missing: |
| 31 | + sys.exit(f'ERROR: repo root {root} is missing expected files: ' + ', '.join(missing)) |
| 32 | + return root |
| 33 | + |
| 34 | + |
| 35 | +def compute_version(root: pathlib.Path, version_kind: str) -> str: |
| 36 | + base = (root / 'version.txt').read_text().strip() |
| 37 | + if version_kind == 'release': |
| 38 | + return base |
| 39 | + timestamp = datetime.datetime.now(datetime.timezone.utc).strftime('%Y%m%d%H%M') |
| 40 | + return f'{base}~{timestamp}' |
| 41 | + |
| 42 | + |
| 43 | +def discover_signing_key() -> tuple[str, str, str]: |
| 44 | + """Return (keyid, debfullname, debemail) from the GPG secret keyring. |
| 45 | +
|
| 46 | + Fails if there are 0 or >1 signing-capable secret keys. |
| 47 | + """ |
| 48 | + result = subprocess.run( |
| 49 | + ['gpg', '--list-secret-keys', '--with-colons'], |
| 50 | + capture_output=True, |
| 51 | + text=True, |
| 52 | + check=True, |
| 53 | + ) |
| 54 | + |
| 55 | + # Collect (keyid, uid_string) pairs for signing-capable keys. |
| 56 | + # Colon format: field 1=type, field 5=keyid, field 12=capabilities. |
| 57 | + # Capabilities field contains 's' for signing. |
| 58 | + # uid lines immediately following a sec block carry field 10 = "Name <email>". |
| 59 | + keys: list[tuple[str, str]] = [] |
| 60 | + current_keyid: str | None = None |
| 61 | + |
| 62 | + for line in result.stdout.splitlines(): |
| 63 | + fields = line.split(':') |
| 64 | + record_type = fields[0] |
| 65 | + |
| 66 | + if record_type == 'sec': |
| 67 | + caps = fields[11] if len(fields) > 11 else '' |
| 68 | + if 's' in caps: |
| 69 | + current_keyid = fields[4] |
| 70 | + else: |
| 71 | + current_keyid = None |
| 72 | + |
| 73 | + elif record_type == 'uid' and current_keyid is not None: |
| 74 | + uid_string = fields[9] if len(fields) > 9 else '' |
| 75 | + if uid_string: |
| 76 | + keys.append((current_keyid, uid_string)) |
| 77 | + current_keyid = None # take only the first uid per key |
| 78 | + |
| 79 | + if not keys: |
| 80 | + sys.exit('ERROR: no signing-capable GPG secret key found.\nGenerate one with: gpg --gen-key') |
| 81 | + |
| 82 | + if len(keys) > 1: |
| 83 | + hint = '\n'.join(f' {keyid} {uid}' for keyid, uid in keys) |
| 84 | + sys.exit( |
| 85 | + 'ERROR: multiple signing-capable GPG secret keys found;\n' |
| 86 | + 'key selection is not parameterized — please leave exactly one:\n' + hint |
| 87 | + ) |
| 88 | + |
| 89 | + keyid, uid_string = keys[0] |
| 90 | + |
| 91 | + # Parse "Full Name <email@example.com>" |
| 92 | + match = re.match(r'^(.*?)\s*<([^>]+)>$', uid_string) |
| 93 | + if not match: |
| 94 | + sys.exit( |
| 95 | + f'ERROR: GPG key UID {uid_string!r} is not in "Name <email>" format.\n' |
| 96 | + 'Update the key UID or set DEBEMAIL/DEBFULLNAME manually.' |
| 97 | + ) |
| 98 | + |
| 99 | + debfullname = match.group(1).strip() |
| 100 | + debemail = match.group(2).strip() |
| 101 | + return keyid, debfullname, debemail |
| 102 | + |
| 103 | + |
| 104 | +def build_env( |
| 105 | + base: dict[str, str], |
| 106 | + distro: str, |
| 107 | + version: str, |
| 108 | + debfullname: str, |
| 109 | + debemail: str, |
| 110 | +) -> dict[str, str]: |
| 111 | + env = base.copy() |
| 112 | + env['DISTRO'] = distro |
| 113 | + env['VERSION'] = version |
| 114 | + env['DEBFULLNAME'] = debfullname |
| 115 | + env['DEBEMAIL'] = debemail |
| 116 | + |
| 117 | + # GPG_TTY is required for gpg-agent to prompt for the passphrase. |
| 118 | + if 'GPG_TTY' not in env: |
| 119 | + try: |
| 120 | + env['GPG_TTY'] = os.ttyname(sys.stdin.fileno()) |
| 121 | + except OSError: |
| 122 | + pass # not a tty; gpg may still work via agent/pinentry |
| 123 | + |
| 124 | + return env |
| 125 | + |
| 126 | + |
| 127 | +def run(description: str, cmd: list[str], **kwargs) -> None: |
| 128 | + print(f'\n=== {description} ===', flush=True) |
| 129 | + print(' '.join(cmd), flush=True) |
| 130 | + try: |
| 131 | + subprocess.run(cmd, check=True, **kwargs) |
| 132 | + except subprocess.CalledProcessError as exc: |
| 133 | + sys.exit(f'ERROR: {description} failed with exit code {exc.returncode}') |
| 134 | + |
| 135 | + |
| 136 | +def main() -> None: |
| 137 | + parser = argparse.ArgumentParser( |
| 138 | + description='Build and upload userver source package to Launchpad PPA.', |
| 139 | + ) |
| 140 | + parser.add_argument( |
| 141 | + '--distro', |
| 142 | + required=True, |
| 143 | + choices=SUPPORTED_DISTROS, |
| 144 | + help='Target Ubuntu distro (e.g. ubuntu-24.04).', |
| 145 | + ) |
| 146 | + parser.add_argument( |
| 147 | + '--version-kind', |
| 148 | + required=True, |
| 149 | + choices=['release', 'nightly'], |
| 150 | + help=('release: use version.txt as-is; nightly: append ~<UTC timestamp> to version.txt.'), |
| 151 | + ) |
| 152 | + args = parser.parse_args() |
| 153 | + |
| 154 | + root = find_repo_root() |
| 155 | + os.chdir(root) |
| 156 | + |
| 157 | + version = compute_version(root, args.version_kind) |
| 158 | + keyid, debfullname, debemail = discover_signing_key() |
| 159 | + |
| 160 | + print(f'VERSION: {version}') |
| 161 | + print(f'DISTRO: {args.distro}') |
| 162 | + print(f'SIGNING KEY: {keyid}') |
| 163 | + print(f'DEBFULLNAME: {debfullname}') |
| 164 | + print(f'DEBEMAIL: {debemail}') |
| 165 | + print(f'PPA: {PPA}') |
| 166 | + |
| 167 | + env = build_env( |
| 168 | + os.environ.copy(), |
| 169 | + distro=args.distro, |
| 170 | + version=version, |
| 171 | + debfullname=debfullname, |
| 172 | + debemail=debemail, |
| 173 | + ) |
| 174 | + |
| 175 | + # Step 1: generate debian/ and vendor pydantic wheels |
| 176 | + run( |
| 177 | + 'Generating debian directory', |
| 178 | + ['scripts/generate-debian-directory.sh'], |
| 179 | + env=env, |
| 180 | + ) |
| 181 | + |
| 182 | + # Step 2: build signed source package |
| 183 | + run( |
| 184 | + 'Building source package', |
| 185 | + ['debuild', '-S', '-sa', f'-k{keyid}'], |
| 186 | + env=env, |
| 187 | + ) |
| 188 | + |
| 189 | + # Step 3: upload |
| 190 | + changes_file = root.parent / f'userver_{version}_source.changes' |
| 191 | + if not changes_file.exists(): |
| 192 | + sys.exit(f'ERROR: expected .changes file not found: {changes_file}\nCheck debuild output above.') |
| 193 | + |
| 194 | + run( |
| 195 | + 'Uploading to Launchpad PPA', |
| 196 | + ['dput', PPA, str(changes_file)], |
| 197 | + env=env, |
| 198 | + ) |
| 199 | + |
| 200 | + print() |
| 201 | + print('Upload complete.') |
| 202 | + print(f' VERSION: {version}') |
| 203 | + print(f' changes: {changes_file}') |
| 204 | + print() |
| 205 | + print( |
| 206 | + 'NOTE: "Successfully uploaded" = FTP transfer only.\n' |
| 207 | + 'Authoritative result is the Launchpad acceptance email\n' |
| 208 | + '(OpenPGP-encrypted; decrypt with: GPG_TTY=$(tty) gpg --decrypt).' |
| 209 | + ) |
| 210 | + |
| 211 | + |
| 212 | +if __name__ == '__main__': |
| 213 | + main() |
0 commit comments