rastrillo / native Public

Clone
git clone https://amadan.net/rastrillo/native

Plain git — no account needed to clone.

Download

Download this file

1"""Prepare a signed, notarized Sparkle archive. Does not publish or install it."""
2import argparse
3import hashlib
4import json
5from pathlib import Path
6import plistlib
7import shutil
8import subprocess
9import tempfile
10
11from mac_updates import install_keys, read_configuration, sign_bundle
12
13MAX_ARCHIVE_BYTES = 100 * 1024 * 1024
14
15
16def run(*args):
17 subprocess.run([str(arg) for arg in args], check=True)
18
19
20def 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
74def 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
87if __name__ == '__main__':
88 main()
89