"""Prepare a signed, notarized Sparkle archive. Does not publish or install it.""" import argparse import hashlib import json from pathlib import Path import plistlib import shutil import subprocess import tempfile from mac_updates import install_keys, read_configuration, sign_bundle MAX_ARCHIVE_BYTES = 100 * 1024 * 1024 def run(*args): subprocess.run([str(arg) for arg in args], check=True) def prepare(app, config_path, tools, identity, team, notary_profile, output): config = read_configuration(config_path) if not identity or identity == '-' or not team or not notary_profile: raise ValueError('Developer ID identity, team and notarization profile are required') info = plistlib.loads((app / 'Contents/Info.plist').read_bytes()) if (info.get('CFBundleIdentifier') != config['bundle_id'] or info.get('SUFeedURL') != config['feed_url'] or info.get('SUPublicEDKey') != config['public_ed_key'] or any(info.get(key) != value for key, value in install_keys(config).items())): raise ValueError('build the app with this update configuration first') build = info['CFBundleVersion'] if not build.isdecimal() or int(build) < 1: raise ValueError('CFBundleVersion must be a positive integer') public_key = subprocess.check_output([str(tools / 'generate_keys'), '--account', config['keychain_account'], '-p'], text=True).strip() if public_key != config['public_ed_key']: raise ValueError('Keychain signing key does not match the bundled public key') archive_name = f"{config['bundle_id']}-{build}.zip" if output.exists(): raise ValueError('output directory must be new to avoid replacing a release') with tempfile.TemporaryDirectory(prefix='oficina-release-') as temporary: stage = Path(temporary) staged_app = stage / app.name shutil.copytree(app, staged_app, symlinks=True) sign_bundle(staged_app, identity) details = subprocess.run(['codesign', '-dv', '--verbose=4', str(staged_app)], capture_output=True, text=True, check=True).stderr if f'TeamIdentifier={team}\n' not in details or 'Authority=Developer ID Application:' not in details: raise ValueError('release was not signed with the selected Developer ID team') submission = stage / 'submission.zip' run('ditto', '-c', '-k', '--keepParent', staged_app, submission) result = subprocess.check_output(['xcrun', 'notarytool', 'submit', str(submission), '--keychain-profile', notary_profile, '--wait', '--output-format', 'json'], text=True) notarization = json.loads(result) if notarization.get('status') != 'Accepted': raise RuntimeError(f"notarization failed; submission {notarization.get('id')}") run('xcrun', 'stapler', 'staple', staged_app) run('xcrun', 'stapler', 'validate', staged_app) run('spctl', '--assess', '--type', 'execute', '--verbose=2', staged_app) archive = stage / archive_name run('ditto', '-c', '-k', '--keepParent', staged_app, archive) if archive.stat().st_size > MAX_ARCHIVE_BYTES: raise ValueError('archive exceeds Amadan raw hosting limit of 100 MiB') signature = subprocess.check_output([str(tools / 'sign_update'), '--account', config['keychain_account'], '-p', str(archive)], text=True).strip() run(tools / 'sign_update', '--account', config['keychain_account'], '--verify', archive, signature) with archive.open('rb') as data: digest = hashlib.file_digest(data, 'sha256').hexdigest() manifest = dict(bundle_id=config['bundle_id'], feed_url=config['feed_url'], build=build, version=info['CFBundleShortVersionString'], archive=archive_name, length=archive.stat().st_size, sha256=digest, signature=signature, team_id=team, notarization_id=notarization['id']) output.mkdir(parents=True) shutil.copy2(archive, output / archive_name) (output / 'release.json').write_text(json.dumps(manifest, indent=2) + '\n') return output def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('app', type=Path) parser.add_argument('--config', type=Path, required=True) parser.add_argument('--sparkle-tools', type=Path, required=True) parser.add_argument('--identity', required=True) parser.add_argument('--team', required=True) parser.add_argument('--notary-profile', required=True) parser.add_argument('--output', type=Path, required=True) args = parser.parse_args() print(prepare(args.app, args.config, args.sparkle_tools, args.identity, args.team, args.notary_profile, args.output)) if __name__ == '__main__': main()