| 1 | """Publish a prepared Sparkle release to an existing public Amadan artifact repo. |
| 2 | |
| 3 | Without --publish, validate and print the publication plan without changing the repo. |
| 4 | The archive is uploaded as an Amadan release asset, never committed to Git; the feed |
| 5 | is pushed only after an anonymous download verifies the asset's length and digest. |
| 6 | """ |
| 7 | import argparse |
| 8 | import hashlib |
| 9 | import json |
| 10 | from pathlib import Path |
| 11 | import re |
| 12 | import plistlib |
| 13 | import zipfile |
| 14 | import shutil |
| 15 | import subprocess |
| 16 | import sys |
| 17 | import tempfile |
| 18 | import time |
| 19 | from urllib.parse import quote, urlsplit |
| 20 | from urllib.request import urlopen |
| 21 | import xml.etree.ElementTree as ET |
| 22 | |
| 23 | from mac_updates import install_keys, read_configuration |
| 24 | from release_mac import MAX_ARCHIVE_BYTES |
| 25 | |
| 26 | SPARKLE = '{http://www.andymatuschak.org/xml-namespaces/sparkle}' |
| 27 | |
| 28 | |
| 29 | def git(repo, *args): |
| 30 | return subprocess.check_output(['git', '-C', str(repo), *args], text=True).strip() |
| 31 | |
| 32 | |
| 33 | def check_build(feed, build): |
| 34 | if not str(build).isdecimal() or int(build) < 1: |
| 35 | raise ValueError('release build must be a positive integer') |
| 36 | if not feed.exists(): |
| 37 | return |
| 38 | root = ET.parse(feed).getroot() |
| 39 | versions = [element.text for element in root.iter(SPARKLE + 'version')] |
| 40 | versions += [node.attrib[SPARKLE + 'version'] for node in root.iter('enclosure') if SPARKLE + 'version' in node.attrib] |
| 41 | if not versions or any(not value or not value.isdecimal() for value in versions): |
| 42 | raise ValueError('existing appcast has no comparable integer versions') |
| 43 | if int(build) <= max(map(int, versions)): |
| 44 | raise ValueError('release build must be greater than every published build') |
| 45 | |
| 46 | |
| 47 | def verify_download(url, expected_length, expected_digest): |
| 48 | for attempt in range(5): |
| 49 | try: |
| 50 | digest = hashlib.sha256() |
| 51 | length = 0 |
| 52 | with urlopen(url, timeout=60) as response: |
| 53 | if urlsplit(response.url).scheme != 'https': |
| 54 | raise ValueError('archive redirected to an insecure URL') |
| 55 | while data := response.read(1024 * 1024): |
| 56 | length += len(data) |
| 57 | if length > expected_length: |
| 58 | raise ValueError('published archive is larger than the signed archive') |
| 59 | digest.update(data) |
| 60 | if length != expected_length or digest.hexdigest() != expected_digest: |
| 61 | raise ValueError('published archive does not match the signed archive') |
| 62 | return |
| 63 | except (OSError, ValueError): |
| 64 | if attempt == 4: |
| 65 | raise |
| 66 | time.sleep(2 ** attempt) |
| 67 | |
| 68 | |
| 69 | def publish(release, config_path, repo, tools, do_publish=False): |
| 70 | config = read_configuration(config_path) |
| 71 | manifest = json.loads((release / 'release.json').read_text()) |
| 72 | if manifest['bundle_id'] != config['bundle_id'] or manifest['feed_url'] != config['feed_url']: |
| 73 | raise ValueError('release and update configuration do not match') |
| 74 | archive_name = manifest['archive'] |
| 75 | if Path(archive_name).name != archive_name or not archive_name.endswith('.zip'): |
| 76 | raise ValueError('invalid archive filename') |
| 77 | archive = release / archive_name |
| 78 | length = archive.stat().st_size |
| 79 | with archive.open('rb') as data: |
| 80 | digest = hashlib.file_digest(data, 'sha256').hexdigest() |
| 81 | if length != manifest['length'] or digest != manifest['sha256'] or length > MAX_ARCHIVE_BYTES: |
| 82 | raise ValueError('archive changed or exceeds the Amadan size limit') |
| 83 | with zipfile.ZipFile(archive) as zipped: |
| 84 | plists = [name for name in zipped.namelist() if re.fullmatch(r'[^/]+\.app/Contents/Info\.plist', name)] |
| 85 | if len(plists) != 1: |
| 86 | raise ValueError('archive must contain exactly one top-level app') |
| 87 | info = plistlib.loads(zipped.read(plists[0])) |
| 88 | expected = {'CFBundleIdentifier': config['bundle_id'], 'SUFeedURL': config['feed_url'], |
| 89 | 'SUPublicEDKey': config['public_ed_key'], 'CFBundleVersion': manifest['build'], |
| 90 | 'CFBundleShortVersionString': manifest['version']} | install_keys(config) |
| 91 | if any(info.get(key) != value for key, value in expected.items()): |
| 92 | raise ValueError('signed archive metadata does not match the release and update configuration') |
| 93 | public_key = subprocess.check_output([str(tools / 'generate_keys'), '--account', config['keychain_account'], '-p'], text=True).strip() |
| 94 | if public_key != config['public_ed_key']: |
| 95 | raise ValueError('Keychain signing key does not match the app public key') |
| 96 | feed = urlsplit(config['feed_url']) |
| 97 | match = re.fullmatch(r'/([a-z0-9_-]+)/([a-z0-9_-]+)/main/([a-z0-9_-]+)/appcast.xml', feed.path) |
| 98 | if feed.netloc != 'raw.amadan.net' or feed.query or not match: |
| 99 | raise ValueError('feed must use an Amadan raw main/<app>/appcast.xml URL') |
| 100 | namespace, repository, app = match.groups() |
| 101 | expected_remote = f'https://amadan.net/{namespace}/{repository}' |
| 102 | if git(repo, 'remote', 'get-url', '--push', 'origin').removesuffix('.git') != expected_remote: |
| 103 | raise ValueError('origin must point to the configured Amadan artifact repository') |
| 104 | if git(repo, 'status', '--porcelain') or git(repo, 'branch', '--show-current') != 'main': |
| 105 | raise ValueError('artifact repository must be clean and on main') |
| 106 | relative_feed = Path(app) / 'appcast.xml' |
| 107 | check_build(repo / relative_feed, manifest['build']) |
| 108 | subprocess.run([str(tools / 'sign_update'), '--account', config['keychain_account'], '--verify', str(archive), manifest['signature']], check=True) |
| 109 | # Zips committed to Git stay in history for good and are copied by every clone. |
| 110 | # On 2026-10-26 a repack of 1.1 GB of them took amadan.net down (native-releases#1), |
| 111 | # so archives are release assets in object storage and Git holds only the feeds. |
| 112 | tag = f"{app}-{manifest['build']}" |
| 113 | prefix = f'{expected_remote}/-/releases/{tag}/' |
| 114 | plan = dict(repository=expected_remote, release=tag, download=prefix + quote(archive_name), feed=config['feed_url'], build=manifest['build']) |
| 115 | if not do_publish: |
| 116 | return plan |
| 117 | git(repo, 'fetch', 'origin', 'main') |
| 118 | if git(repo, 'rev-parse', 'HEAD') != git(repo, 'rev-parse', 'origin/main'): |
| 119 | raise ValueError('main must exactly match the fetched origin/main before publication') |
| 120 | # A release needs a pushed tag. A rerun keeps the tag an earlier run made. |
| 121 | if not git(repo, 'tag', '--list', tag): |
| 122 | git(repo, 'tag', tag, 'HEAD') |
| 123 | git(repo, 'push', 'origin', f'refs/tags/{tag}') |
| 124 | with tempfile.TemporaryDirectory(prefix='oficina-release-') as directory: |
| 125 | notes = Path(directory) / 'notes.md' |
| 126 | notes.write_text(f"{info.get('CFBundleName', app)} {manifest['version']} (build {manifest['build']})\n") |
| 127 | upload = subprocess.run(['amadan', 'release', 'create', '-hub', f'https://{urlsplit(expected_remote).netloc}', |
| 128 | '-repo', f'{namespace}/{repository}', '-notes-file', str(notes), tag, str(archive)]) |
| 129 | # An asset is immutable once uploaded, so a rerun's upload is refused. That is only |
| 130 | # acceptable when the public download already has exactly the signed bytes. |
| 131 | verify_download(prefix + quote(archive_name), length, digest) |
| 132 | if upload.returncode != 0: |
| 133 | print(f'release upload exited {upload.returncode}, but the published asset matches the signed archive', file=sys.stderr) |
| 134 | with tempfile.TemporaryDirectory(prefix='oficina-appcast-') as directory: |
| 135 | staging = Path(directory) |
| 136 | shutil.copy2(archive, staging / archive_name) |
| 137 | output = staging / 'appcast.xml' |
| 138 | if (repo / relative_feed).exists(): |
| 139 | shutil.copy2(repo / relative_feed, output) |
| 140 | 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) |
| 141 | tree = ET.parse(output) |
| 142 | matching_items = [item for item in tree.iter('item') if any(node.get('url') == prefix + quote(archive_name) for node in item.findall('enclosure'))] |
| 143 | if len(matching_items) != 1: |
| 144 | raise ValueError('generated appcast must have one matching release item') |
| 145 | actual_build = matching_items[0].findtext(SPARKLE + 'version') |
| 146 | if actual_build != manifest['build']: |
| 147 | raise ValueError('generated appcast build does not match the signed archive') |
| 148 | check_build(repo / relative_feed, actual_build) |
| 149 | enclosures = [node for node in tree.iter('enclosure') if node.get('url') == prefix + quote(archive_name)] |
| 150 | if len(enclosures) != 1 or enclosures[0].get(SPARKLE + 'edSignature') != manifest['signature'] or enclosures[0].get('length') != str(length): |
| 151 | raise ValueError('generated appcast does not reference the verified signed archive') |
| 152 | (repo / relative_feed).parent.mkdir(parents=True, exist_ok=True) |
| 153 | shutil.copy2(output, repo / relative_feed) |
| 154 | git(repo, 'add', '--', str(relative_feed)) |
| 155 | git(repo, 'commit', '-m', f"Publish {app} build {manifest['build']} appcast") |
| 156 | git(repo, 'push', 'origin', 'HEAD:refs/heads/main') |
| 157 | return dict(plan, feed_commit=git(repo, 'rev-parse', 'HEAD')) |
| 158 | |
| 159 | |
| 160 | def main(): |
| 161 | parser = argparse.ArgumentParser(description=__doc__) |
| 162 | parser.add_argument('release', type=Path) |
| 163 | parser.add_argument('--config', type=Path, required=True) |
| 164 | parser.add_argument('--repo', type=Path, required=True) |
| 165 | parser.add_argument('--sparkle-tools', type=Path, required=True) |
| 166 | parser.add_argument('--publish', action='store_true') |
| 167 | args = parser.parse_args() |
| 168 | print(json.dumps(publish(args.release, args.config, args.repo, args.sparkle_tools, args.publish), indent=2)) |
| 169 | |
| 170 | |
| 171 | if __name__ == '__main__': |
| 172 | main() |
| 173 | |