|
| 1 | +#!/usr/bin/env python3 |
| 2 | +import argparse, os, sys, subprocess, xml.etree.ElementTree as ET, hashlib, base64, shlex |
| 3 | + |
| 4 | +def die(msg): |
| 5 | + sys.stderr.write(msg+"\n"); sys.exit(1) |
| 6 | + |
| 7 | +def which(cmd): |
| 8 | + from shutil import which as w |
| 9 | + return w(cmd) |
| 10 | + |
| 11 | +def run(cmd, input_bytes=None): |
| 12 | + p = subprocess.Popen(cmd, stdin=subprocess.PIPE if input_bytes else None, stdout=subprocess.PIPE, stderr=subprocess.PIPE) |
| 13 | + out, err = p.communicate(input=input_bytes) |
| 14 | + if p.returncode != 0: |
| 15 | + raise RuntimeError("command failed: %s\n%s" % (" ".join(cmd), err.decode('utf-8','replace'))) |
| 16 | + return out |
| 17 | + |
| 18 | +def sha256_str(s: str) -> str: |
| 19 | + return hashlib.sha256(s.encode('utf-8')).hexdigest() |
| 20 | + |
| 21 | +def main(): |
| 22 | + ap = argparse.ArgumentParser(description='Sync Central/GPG secrets from local ~/.m2/settings.xml and gpg to GitHub Actions secrets (hashed preview + optional set).') |
| 23 | + ap.add_argument('--settings', default=os.path.expanduser('~/.m2/settings.xml')) |
| 24 | + ap.add_argument('--repo', default=None, help='owner/repo for gh secret operations (defaults to current)') |
| 25 | + ap.add_argument('--key-id', default=None, help='GPG key id/fingerprint to export; auto-detect if omitted') |
| 26 | + ap.add_argument('--set', action='store_true', help='Write secrets to GitHub (CENTRAL_USERNAME, CENTRAL_PASSWORD, GPG_PASSPHRASE, GPG_PRIVATE_KEY, GPG_KEYNAME)') |
| 27 | + ap.add_argument('--delete-first', action='store_true', help='Delete existing secrets before setting') |
| 28 | + args = ap.parse_args() |
| 29 | + |
| 30 | + if not os.path.exists(args.settings): |
| 31 | + die('Settings not found: %s' % args.settings) |
| 32 | + |
| 33 | + # Parse settings.xml |
| 34 | + tree = ET.parse(args.settings) |
| 35 | + root = tree.getroot() |
| 36 | + |
| 37 | + ns = {} |
| 38 | + # servers/server[id=central] |
| 39 | + central_user = central_pass = None |
| 40 | + for srv in root.findall('./servers/server', ns): |
| 41 | + sid = (srv.findtext('id') or '').strip() |
| 42 | + if sid == 'central': |
| 43 | + central_user = (srv.findtext('username') or '').strip() |
| 44 | + central_pass = (srv.findtext('password') or '').strip() |
| 45 | + break |
| 46 | + if not central_user or not central_pass: |
| 47 | + die('Could not find <server id="central"> with username/password in %s' % args.settings) |
| 48 | + |
| 49 | + # gpg.passphrase (unique) |
| 50 | + passphrases = [] |
| 51 | + for prop in root.findall('.//profiles/profile/properties', ns): |
| 52 | + val = prop.findtext('gpg.passphrase') |
| 53 | + if val and val.strip(): |
| 54 | + passphrases.append(val.strip()) |
| 55 | + uniq = sorted(set(passphrases)) |
| 56 | + if len(uniq) != 1: |
| 57 | + die('Expected exactly one gpg.passphrase; found %d: %s' % (len(uniq), ','.join(uniq))) |
| 58 | + gpg_pass = uniq[0] |
| 59 | + |
| 60 | + if not which('gpg'): |
| 61 | + die('gpg not found on PATH') |
| 62 | + |
| 63 | + key_id = args.key_id |
| 64 | + if not key_id: |
| 65 | + # auto-detect signing-capable key (sec with s capability) |
| 66 | + out = run(['gpg','--list-secret-keys','--with-colons']).decode('utf-8','replace').splitlines() |
| 67 | + for line in out: |
| 68 | + parts = line.split(':') |
| 69 | + if len(parts) > 12 and parts[0] == 'sec' and 's' in parts[11]: |
| 70 | + key_id = parts[4] |
| 71 | + break |
| 72 | + if not key_id: |
| 73 | + die('No signing-capable secret key found (looked for sec with s capability)') |
| 74 | + |
| 75 | + # fingerprint from key |
| 76 | + fpr = None |
| 77 | + out = run(['gpg','--with-colons','--list-secret-keys', key_id]).decode('utf-8','replace').splitlines() |
| 78 | + for line in out: |
| 79 | + parts = line.split(':') |
| 80 | + if parts and parts[0] == 'fpr' and len(parts) > 9: |
| 81 | + fpr = parts[9] |
| 82 | + break |
| 83 | + if not fpr: |
| 84 | + die('Could not extract fingerprint for key %s' % key_id) |
| 85 | + |
| 86 | + # export armored private key using loopback/passphrase; if agent rejects, fallback without pass |
| 87 | + try: |
| 88 | + armored = run(['gpg','--batch','--yes','--pinentry-mode','loopback','--passphrase', gpg_pass, '--armor','--export-secret-keys', key_id]).decode('utf-8','replace') |
| 89 | + except Exception: |
| 90 | + armored = run(['gpg','--armor','--export-secret-keys', key_id]).decode('utf-8','replace') |
| 91 | + |
| 92 | + # Hashes for comparison |
| 93 | + creds_hash = sha256_str(central_user + ':' + central_pass) |
| 94 | + pass_hash = sha256_str(gpg_pass) |
| 95 | + fpr_hash = sha256_str(fpr) |
| 96 | + priv_hash = hashlib.sha256(armored.encode('utf-8')).hexdigest() |
| 97 | + |
| 98 | + print('Central user: %s' % central_user) |
| 99 | + print('Key fingerprint: %s' % fpr) |
| 100 | + print('SHA256 central(user:pass): %s' % creds_hash) |
| 101 | + print('SHA256 gpg.passphrase: %s' % pass_hash) |
| 102 | + print('SHA256 gpg.keyname(fingerprint): %s' % fpr_hash) |
| 103 | + print('SHA256 armored private key: %s' % priv_hash) |
| 104 | + |
| 105 | + if args.set: |
| 106 | + if not which('gh'): |
| 107 | + die('gh not found on PATH (needed for --set)') |
| 108 | + repo_flag = ['--repo', args.repo] if args.repo else [] |
| 109 | + if args.delete_first: |
| 110 | + for name in ['CENTRAL_USERNAME','CENTRAL_PASSWORD','GPG_PASSPHRASE','GPG_PRIVATE_KEY','GPG_KEYNAME']: |
| 111 | + subprocess.run(['gh','secret','delete',name,'--app','actions'] + repo_flag, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) |
| 112 | + # set secrets |
| 113 | + for name, value in [ |
| 114 | + ('CENTRAL_USERNAME', central_user), |
| 115 | + ('CENTRAL_PASSWORD', central_pass), |
| 116 | + ('GPG_PASSPHRASE', gpg_pass), |
| 117 | + ('GPG_PRIVATE_KEY', armored), |
| 118 | + ('GPG_KEYNAME', fpr), |
| 119 | + ]: |
| 120 | + proc = subprocess.Popen(['gh','secret','set',name,'--app','actions'] + repo_flag, stdin=subprocess.PIPE) |
| 121 | + proc.communicate(input=value.encode('utf-8')) |
| 122 | + if proc.returncode != 0: |
| 123 | + die('failed to set secret %s' % name) |
| 124 | + print('Secrets updated in GitHub (Actions app).') |
| 125 | + |
| 126 | +if __name__ == '__main__': |
| 127 | + main() |
| 128 | + |
0 commit comments