| 1 | """Prepare a signed, notarized Sparkle archive. Does not publish or install it.""" |
| 2 | import argparse |
| 3 | import hashlib |
| 4 | import json |
| 5 | from pathlib import Path |
| 6 | import plistlib |
| 7 | import shutil |
| 8 | import subprocess |
| 9 | import tempfile |
| 10 | |
| 11 | from mac_updates import install_keys, read_configuration, sign_bundle |
| 12 | |
| 13 | MAX_ARCHIVE_BYTES = 100 * 1024 * 1024 |
| 14 | |
| 15 | |
| 16 | def run(*args): |
| 17 | subprocess.run([str(arg) for arg in args], check=True) |
| 18 | |
| 19 | |
| 20 | def prepare(app, config_path, tools, identity, team, notary_profile, output): |
| 21 | config = read_configuration(config_path) |
| 22 | if not identity or identity == '-' or not team or not notary_profile: |
| 23 | raise ValueError('Developer ID identity, team and notarization profile are required') |
| 24 | info = plistlib.loads((app / 'Contents/Info.plist').read_bytes()) |
| 25 | if (info.get('CFBundleIdentifier') != config['bundle_id'] or |
| 26 | info.get('SUFeedURL') != config['feed_url'] or |
| 27 | info.get('SUPublicEDKey') != config['public_ed_key'] or |
| 28 | any(info.get(key) != value for key, value in install_keys(config).items())): |
| 29 | raise ValueError('build the app with this update configuration first') |
| 30 | build = info['CFBundleVersion'] |
| 31 | if not build.isdecimal() or int(build) < 1: |
| 32 | raise ValueError('CFBundleVersion must be a positive integer') |
| 33 | public_key = subprocess.check_output([str(tools / 'generate_keys'), '--account', config['keychain_account'], '-p'], text=True).strip() |
| 34 | if public_key != config['public_ed_key']: |
| 35 | raise ValueError('Keychain signing key does not match the bundled public key') |
| 36 | archive_name = f"{config['bundle_id']}-{build}.zip" |
| 37 | if output.exists(): |
| 38 | raise ValueError('output directory must be new to avoid replacing a release') |
| 39 | with tempfile.TemporaryDirectory(prefix='oficina-release-') as temporary: |
| 40 | stage = Path(temporary) |
| 41 | staged_app = stage / app.name |
| 42 | shutil.copytree(app, staged_app, symlinks=True) |
| 43 | sign_bundle(staged_app, identity) |
| 44 | details = subprocess.run(['codesign', '-dv', '--verbose=4', str(staged_app)], capture_output=True, text=True, check=True).stderr |
| 45 | if f'TeamIdentifier={team}\n' not in details or 'Authority=Developer ID Application:' not in details: |
| 46 | raise ValueError('release was not signed with the selected Developer ID team') |
| 47 | submission = stage / 'submission.zip' |
| 48 | run('ditto', '-c', '-k', '--keepParent', staged_app, submission) |
| 49 | result = subprocess.check_output(['xcrun', 'notarytool', 'submit', str(submission), '--keychain-profile', notary_profile, '--wait', '--output-format', 'json'], text=True) |
| 50 | notarization = json.loads(result) |
| 51 | if notarization.get('status') != 'Accepted': |
| 52 | raise RuntimeError(f"notarization failed; submission {notarization.get('id')}") |
| 53 | run('xcrun', 'stapler', 'staple', staged_app) |
| 54 | run('xcrun', 'stapler', 'validate', staged_app) |
| 55 | run('spctl', '--assess', '--type', 'execute', '--verbose=2', staged_app) |
| 56 | archive = stage / archive_name |
| 57 | run('ditto', '-c', '-k', '--keepParent', staged_app, archive) |
| 58 | if archive.stat().st_size > MAX_ARCHIVE_BYTES: |
| 59 | raise ValueError('archive exceeds Amadan raw hosting limit of 100 MiB') |
| 60 | signature = subprocess.check_output([str(tools / 'sign_update'), '--account', config['keychain_account'], '-p', str(archive)], text=True).strip() |
| 61 | run(tools / 'sign_update', '--account', config['keychain_account'], '--verify', archive, signature) |
| 62 | with archive.open('rb') as data: |
| 63 | digest = hashlib.file_digest(data, 'sha256').hexdigest() |
| 64 | manifest = dict(bundle_id=config['bundle_id'], feed_url=config['feed_url'], build=build, |
| 65 | version=info['CFBundleShortVersionString'], archive=archive_name, |
| 66 | length=archive.stat().st_size, sha256=digest, |
| 67 | signature=signature, team_id=team, notarization_id=notarization['id']) |
| 68 | output.mkdir(parents=True) |
| 69 | shutil.copy2(archive, output / archive_name) |
| 70 | (output / 'release.json').write_text(json.dumps(manifest, indent=2) + '\n') |
| 71 | return output |
| 72 | |
| 73 | |
| 74 | def main(): |
| 75 | parser = argparse.ArgumentParser(description=__doc__) |
| 76 | parser.add_argument('app', type=Path) |
| 77 | parser.add_argument('--config', type=Path, required=True) |
| 78 | parser.add_argument('--sparkle-tools', type=Path, required=True) |
| 79 | parser.add_argument('--identity', required=True) |
| 80 | parser.add_argument('--team', required=True) |
| 81 | parser.add_argument('--notary-profile', required=True) |
| 82 | parser.add_argument('--output', type=Path, required=True) |
| 83 | args = parser.parse_args() |
| 84 | print(prepare(args.app, args.config, args.sparkle_tools, args.identity, args.team, args.notary_profile, args.output)) |
| 85 | |
| 86 | |
| 87 | if __name__ == '__main__': |
| 88 | main() |
| 89 | |