rastrillo / native Public

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

Plain git — no account needed to clone.

Download

Download this file

1"""Publish a prepared Sparkle release to an existing public Amadan artifact repo.
2
3Without --publish, validate and print the publication plan without changing the repo.
4The archive is uploaded as an Amadan release asset, never committed to Git; the feed
5is pushed only after an anonymous download verifies the asset's length and digest.
6"""
7import argparse
8import hashlib
9import json
10from pathlib import Path
11import re
12import plistlib
13import zipfile
14import shutil
15import subprocess
16import sys
17import tempfile
18import time
19from urllib.parse import quote, urlsplit
20from urllib.request import urlopen
21import xml.etree.ElementTree as ET
22
23from mac_updates import install_keys, read_configuration
24from release_mac import MAX_ARCHIVE_BYTES
25
26SPARKLE = '{http://www.andymatuschak.org/xml-namespaces/sparkle}'
27
28
29def git(repo, *args):
30 return subprocess.check_output(['git', '-C', str(repo), *args], text=True).strip()
31
32
33def 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
47def 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
69def 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
160def 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
171if __name__ == '__main__':
172 main()
173