"""Publish a prepared Sparkle release to an existing public Amadan artifact repo. Without --publish, validate and print the publication plan without changing the repo. The archive is uploaded as an Amadan release asset, never committed to Git; the feed is pushed only after an anonymous download verifies the asset's length and digest. """ import argparse import hashlib import json from pathlib import Path import re import plistlib import zipfile import shutil import subprocess import sys import tempfile import time from urllib.parse import quote, urlsplit from urllib.request import urlopen import xml.etree.ElementTree as ET from mac_updates import install_keys, read_configuration from release_mac import MAX_ARCHIVE_BYTES SPARKLE = '{http://www.andymatuschak.org/xml-namespaces/sparkle}' def git(repo, *args): return subprocess.check_output(['git', '-C', str(repo), *args], text=True).strip() def check_build(feed, build): if not str(build).isdecimal() or int(build) < 1: raise ValueError('release build must be a positive integer') if not feed.exists(): return root = ET.parse(feed).getroot() versions = [element.text for element in root.iter(SPARKLE + 'version')] versions += [node.attrib[SPARKLE + 'version'] for node in root.iter('enclosure') if SPARKLE + 'version' in node.attrib] if not versions or any(not value or not value.isdecimal() for value in versions): raise ValueError('existing appcast has no comparable integer versions') if int(build) <= max(map(int, versions)): raise ValueError('release build must be greater than every published build') def verify_download(url, expected_length, expected_digest): for attempt in range(5): try: digest = hashlib.sha256() length = 0 with urlopen(url, timeout=60) as response: if urlsplit(response.url).scheme != 'https': raise ValueError('archive redirected to an insecure URL') while data := response.read(1024 * 1024): length += len(data) if length > expected_length: raise ValueError('published archive is larger than the signed archive') digest.update(data) if length != expected_length or digest.hexdigest() != expected_digest: raise ValueError('published archive does not match the signed archive') return except (OSError, ValueError): if attempt == 4: raise time.sleep(2 ** attempt) def publish(release, config_path, repo, tools, do_publish=False): config = read_configuration(config_path) manifest = json.loads((release / 'release.json').read_text()) if manifest['bundle_id'] != config['bundle_id'] or manifest['feed_url'] != config['feed_url']: raise ValueError('release and update configuration do not match') archive_name = manifest['archive'] if Path(archive_name).name != archive_name or not archive_name.endswith('.zip'): raise ValueError('invalid archive filename') archive = release / archive_name length = archive.stat().st_size with archive.open('rb') as data: digest = hashlib.file_digest(data, 'sha256').hexdigest() if length != manifest['length'] or digest != manifest['sha256'] or length > MAX_ARCHIVE_BYTES: raise ValueError('archive changed or exceeds the Amadan size limit') with zipfile.ZipFile(archive) as zipped: plists = [name for name in zipped.namelist() if re.fullmatch(r'[^/]+\.app/Contents/Info\.plist', name)] if len(plists) != 1: raise ValueError('archive must contain exactly one top-level app') info = plistlib.loads(zipped.read(plists[0])) expected = {'CFBundleIdentifier': config['bundle_id'], 'SUFeedURL': config['feed_url'], 'SUPublicEDKey': config['public_ed_key'], 'CFBundleVersion': manifest['build'], 'CFBundleShortVersionString': manifest['version']} | install_keys(config) if any(info.get(key) != value for key, value in expected.items()): raise ValueError('signed archive metadata does not match the release and update configuration') 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 app public key') feed = urlsplit(config['feed_url']) match = re.fullmatch(r'/([a-z0-9_-]+)/([a-z0-9_-]+)/main/([a-z0-9_-]+)/appcast.xml', feed.path) if feed.netloc != 'raw.amadan.net' or feed.query or not match: raise ValueError('feed must use an Amadan raw main//appcast.xml URL') namespace, repository, app = match.groups() expected_remote = f'https://amadan.net/{namespace}/{repository}' if git(repo, 'remote', 'get-url', '--push', 'origin').removesuffix('.git') != expected_remote: raise ValueError('origin must point to the configured Amadan artifact repository') if git(repo, 'status', '--porcelain') or git(repo, 'branch', '--show-current') != 'main': raise ValueError('artifact repository must be clean and on main') relative_feed = Path(app) / 'appcast.xml' check_build(repo / relative_feed, manifest['build']) subprocess.run([str(tools / 'sign_update'), '--account', config['keychain_account'], '--verify', str(archive), manifest['signature']], check=True) # Zips committed to Git stay in history for good and are copied by every clone. # On 2026-10-26 a repack of 1.1 GB of them took amadan.net down (native-releases#1), # so archives are release assets in object storage and Git holds only the feeds. tag = f"{app}-{manifest['build']}" prefix = f'{expected_remote}/-/releases/{tag}/' plan = dict(repository=expected_remote, release=tag, download=prefix + quote(archive_name), feed=config['feed_url'], build=manifest['build']) if not do_publish: return plan git(repo, 'fetch', 'origin', 'main') if git(repo, 'rev-parse', 'HEAD') != git(repo, 'rev-parse', 'origin/main'): raise ValueError('main must exactly match the fetched origin/main before publication') # A release needs a pushed tag. A rerun keeps the tag an earlier run made. if not git(repo, 'tag', '--list', tag): git(repo, 'tag', tag, 'HEAD') git(repo, 'push', 'origin', f'refs/tags/{tag}') with tempfile.TemporaryDirectory(prefix='oficina-release-') as directory: notes = Path(directory) / 'notes.md' notes.write_text(f"{info.get('CFBundleName', app)} {manifest['version']} (build {manifest['build']})\n") upload = subprocess.run(['amadan', 'release', 'create', '-hub', f'https://{urlsplit(expected_remote).netloc}', '-repo', f'{namespace}/{repository}', '-notes-file', str(notes), tag, str(archive)]) # An asset is immutable once uploaded, so a rerun's upload is refused. That is only # acceptable when the public download already has exactly the signed bytes. verify_download(prefix + quote(archive_name), length, digest) if upload.returncode != 0: print(f'release upload exited {upload.returncode}, but the published asset matches the signed archive', file=sys.stderr) with tempfile.TemporaryDirectory(prefix='oficina-appcast-') as directory: staging = Path(directory) shutil.copy2(archive, staging / archive_name) output = staging / 'appcast.xml' if (repo / relative_feed).exists(): shutil.copy2(repo / relative_feed, output) subprocess.run([str(tools / 'generate_appcast'), '--account', config['keychain_account'], '--maximum-deltas', '0', '--maximum-versions', '0', '--download-url-prefix', prefix, '-o', str(output), str(staging)], check=True) tree = ET.parse(output) matching_items = [item for item in tree.iter('item') if any(node.get('url') == prefix + quote(archive_name) for node in item.findall('enclosure'))] if len(matching_items) != 1: raise ValueError('generated appcast must have one matching release item') actual_build = matching_items[0].findtext(SPARKLE + 'version') if actual_build != manifest['build']: raise ValueError('generated appcast build does not match the signed archive') check_build(repo / relative_feed, actual_build) enclosures = [node for node in tree.iter('enclosure') if node.get('url') == prefix + quote(archive_name)] if len(enclosures) != 1 or enclosures[0].get(SPARKLE + 'edSignature') != manifest['signature'] or enclosures[0].get('length') != str(length): raise ValueError('generated appcast does not reference the verified signed archive') (repo / relative_feed).parent.mkdir(parents=True, exist_ok=True) shutil.copy2(output, repo / relative_feed) git(repo, 'add', '--', str(relative_feed)) git(repo, 'commit', '-m', f"Publish {app} build {manifest['build']} appcast") git(repo, 'push', 'origin', 'HEAD:refs/heads/main') return dict(plan, feed_commit=git(repo, 'rev-parse', 'HEAD')) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('release', type=Path) parser.add_argument('--config', type=Path, required=True) parser.add_argument('--repo', type=Path, required=True) parser.add_argument('--sparkle-tools', type=Path, required=True) parser.add_argument('--publish', action='store_true') args = parser.parse_args() print(json.dumps(publish(args.release, args.config, args.repo, args.sparkle_tools, args.publish), indent=2)) if __name__ == '__main__': main()