Publish Mac archives as release assets, never in Git
oficina/native-releases#1: about 25 Calls zips (1.1 GB) committed to the artifacts repo made a hub repack exhaust amadan.net's memory and took the site down on 2026-10-26. publish_mac_update.py now pushes a <app>-<build> tag, uploads the archive with `amadan release create`, verifies the anonymous download of amadan.net/<ns>/<repo>/-/releases/<tag>/<zip> against the signed bytes, and only then generates and pushes the appcast. Git gets the feed only. A rerun is the recovery: the tag is reused, and a refused upload (assets are immutable) is accepted only when the public download already matches the signed archive. Tests cover the ordering, that no archive is ever added to Git, and both rerun outcomes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3 files changed,
+101
−44
docs/mac-updates.md+37 −19tools/publish_mac_update.py+23 −16tools/tests/test_mac_updates.py+41 −9
CI failed — run details
CI log
=== test === swift test Fetching https://github.com/sparkle-project/Sparkle from cache Fetched https://github.com/sparkle-project/Sparkle from cache (0.91s) Computing version for https://github.com/sparkle-project/Sparkle Computed https://github.com/sparkle-project/Sparkle at 2.9.6 (1.18s) Creating working copy for https://github.com/sparkle-project/Sparkle Working copy of https://github.com/sparkle-project/Sparkle resolved at 2.9.6 Fetching binary artifact https://github.com/sparkle-project/Sparkle/releases/download/2.9.6/Sparkle-for-Swift-Package-Manager.zip from cache error: failed validating archive from 'https://github.com/sparkle-project/Sparkle/releases/download/2.9.6/Sparkle-for-Swift-Package-Manager.zip' which is required by binary target 'Sparkle': could not find executable for 'unzip' error: fatalError make: *** [Makefile:4: test] Error 1 amadan: step test failed: exit status 2 amadan: job failed at step test — its output is above, not at the tail of this log
diff --git a/docs/mac-updates.md b/docs/mac-updates.md| index 7cf5269..47c33a6 100644 |
| --- a/docs/mac-updates.md |
| +++ b/docs/mac-updates.md |
| @@ -89,27 +89,44 @@ Do not change the visibility of any application source repository. Initialize it |
| https://raw.amadan.net/oficina/native-releases/main/<app>/appcast.xml |
| ``` |
| -Archives use full immutable Git commit SHA URLs. Amadan raw hosting caps each |
| -file at 100 MiB; its Git receive request limit is 128 MiB. The publisher rejects |
| -oversize archives and pushes one archive at a time. Public raw responses need no |
| -application credentials. Pages hosting and expiring private download URLs are |
| -not used. |
| +Git holds the feeds and nothing else. Each archive is an Amadan release asset, |
| +stored in object storage, on a release tagged `<app>-<build>`: |
| + |
| +``` |
| +https://amadan.net/oficina/native-releases/-/releases/<app>-<build>/<archive>.zip |
| +``` |
| + |
| +That URL redirects to a short-lived signed download, which Sparkle follows. |
| +Never commit an archive: every zip in Git stays in history for good and is |
| +copied by every clone. On 2026-10-26 a repack of about 1.1 GB of them took |
| +amadan.net down for ten minutes (`oficina/native-releases#1`). Assets can be up |
| +to 500 MB each, and `release_mac.py` still caps archives at 100 MiB. |
| Run `tools/publish_mac_update.py RELEASE --config CONFIG --repo ARTIFACT_CLONE |
| --sparkle-tools TOOLS` to validate and print a plan. Add `--publish` only when the |
| concrete release is ready to publish. It verifies the archive signature, actual |
| archived app metadata, manifest, Keychain public key, monotonic build number, |
| -clean main checkout and correct origin. It pushes the archive first, downloads |
| -it anonymously by immutable SHA URL and checks length and SHA-256. Only then does |
| -it generate and push the appcast using Sparkle's official tool. The generated |
| -version must match the signed app. Existing feed items are preserved; deltas are |
| -disabled to keep upload sizes bounded. |
| - |
| -If publication stops after pushing an archive, the old feed stays usable. This |
| -first implementation deliberately refuses to overwrite that archive on rerun. |
| -Inspect the committed archive and failure, then either publish a new higher build |
| -or perform a reviewed manual feed recovery referencing that exact verified SHA. |
| -Do not force-push or replace a published archive. |
| +clean main checkout and correct origin. It pushes the release tag and uploads the |
| +archive with `amadan release create`. Then it downloads the archive anonymously |
| +and checks its length and SHA-256. Only after that does it generate and push the |
| +appcast, using Sparkle's official tool. The generated version must match the |
| +signed app. Existing feed items are preserved; deltas are disabled to keep |
| +upload sizes bounded. |
| + |
| +`amadan release create` needs writer access to the artifact repository. Set |
| +`AMADAN_HOME` to the publishing account's credentials (an agent publishes as its |
| +agent account), and use a Git credential for that same account to push the tag |
| +and feed. |
| + |
| +If publication stops after uploading, the old feed stays usable. Rerun the same |
| +command. The tag is reused, and an asset cannot be replaced, so the second upload |
| +is refused. The publisher accepts that refusal only when the public download |
| +already matches the signed archive, and then publishes the feed. A different |
| +archive for a build already uploaded needs a new build number. |
| + |
| +Older feed items point at zips committed before this change, by full commit SHA |
| +on `raw.amadan.net`. They keep working. Removing those zips from history is a |
| +separate decision, for the repository's owner. |
| Users of pre-Sparkle builds need one initial installation of a Sparkle-enabled |
| release. Subsequent releases can update in place. The first publication should |
| @@ -119,7 +136,8 @@ include a real update/relaunch check before broad distribution. |
| `make ci` includes Swift tests, Mac/iOS companion builds, Go race tests, relocated |
| resource packaging and release-boundary tests. Publication tests check that |
| -metadata tampering, archive tampering, failed public download and generated |
| -version mismatch cannot publish a feed; successful publication must verify the |
| -archive between the two pushes. Independent mutation probes confirmed these |
| +metadata tampering, archive tampering, failed public download, a refused upload |
| +without a matching asset and generated version mismatch cannot publish a feed. |
| +They also check that no archive is ever added to Git, and that the archive is |
| +uploaded and verified between the tag push and the feed push. Independent mutation probes confirmed these |
| checks detect removal of the corresponding guards. |
diff --git a/tools/publish_mac_update.py b/tools/publish_mac_update.py| index 059ef6f..58590c7 100644 |
| --- a/tools/publish_mac_update.py |
| +++ b/tools/publish_mac_update.py |
| @@ -1,8 +1,8 @@ |
| """Publish a prepared Sparkle release to an existing public Amadan artifact repo. |
| Without --publish, validate and print the publication plan without changing the repo. |
| -Archives are committed and pushed individually; the feed is pushed only after an |
| -anonymous download verifies the immutable archive's complete length and digest. |
| +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 |
| @@ -13,6 +13,7 @@ import plistlib |
| import zipfile |
| import shutil |
| import subprocess |
| +import sys |
| import tempfile |
| import time |
| from urllib.parse import quote, urlsplit |
| @@ -102,28 +103,34 @@ def publish(release, config_path, repo, tools, do_publish=False): |
| 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_archive = Path(app) / 'releases' / archive_name |
| relative_feed = Path(app) / 'appcast.xml' |
| check_build(repo / relative_feed, manifest['build']) |
| - if (repo / relative_archive).exists(): |
| - raise ValueError('release archive path already exists; use a new build number') |
| subprocess.run([str(tools / 'sign_update'), '--account', config['keychain_account'], '--verify', str(archive), manifest['signature']], check=True) |
| - plan = dict(repository=expected_remote, archive=str(relative_archive), feed=config['feed_url'], build=manifest['build']) |
| + # 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') |
| - target = repo / relative_archive |
| - target.parent.mkdir(parents=True, exist_ok=True) |
| - shutil.copy2(archive, target) |
| - git(repo, 'add', '--', str(relative_archive)) |
| - git(repo, 'commit', '-m', f"Release {app} build {manifest['build']} archive") |
| - archive_commit = git(repo, 'rev-parse', 'HEAD') |
| - prefix = f'https://raw.amadan.net/{namespace}/{repository}/{archive_commit}/{app}/releases/' |
| - # One archive per push stays below Amadan's 128 MiB git receive limit. |
| - git(repo, 'push', 'origin', 'HEAD:refs/heads/main') |
| + # 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) |
| @@ -146,7 +153,7 @@ def publish(release, config_path, repo, tools, do_publish=False): |
| 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, archive_commit=archive_commit, feed_commit=git(repo, 'rev-parse', 'HEAD')) |
| + return dict(plan, feed_commit=git(repo, 'rev-parse', 'HEAD')) |
| def main(): |
diff --git a/tools/tests/test_mac_updates.py b/tools/tests/test_mac_updates.py| index b7b03c0..76b7edd 100644 |
| --- a/tools/tests/test_mac_updates.py |
| +++ b/tools/tests/test_mac_updates.py |
| @@ -4,6 +4,7 @@ import hashlib |
| import json |
| from pathlib import Path |
| import plistlib |
| +import subprocess |
| import sys |
| import tempfile |
| import unittest |
| @@ -130,6 +131,7 @@ class PublicationBoundaryTests(unittest.TestCase): |
| self.events = [] |
| self.generated_build = '3' |
| self.download_error = None |
| + self.upload_returncode = 0 |
| self.addCleanup(patch.stopall) |
| patch('publish_mac_update.git', side_effect=self.git).start() |
| patch('publish_mac_update.subprocess.check_output', return_value=self.values['public_ed_key']).start() |
| @@ -152,11 +154,13 @@ class PublicationBoundaryTests(unittest.TestCase): |
| return 'main' |
| if args[0] == 'rev-parse': |
| return 'a' * 40 |
| - return '' |
| + return '' # also "git tag --list": no tag from an earlier run |
| def tool(self, command, **kwargs): |
| tool = Path(command[0]).name |
| - self.events.append((tool,)) |
| + self.events.append((tool, *command[1:3]) if tool == 'amadan' else (tool,)) |
| + if tool == 'amadan': |
| + return subprocess.CompletedProcess(command, self.upload_returncode) |
| if tool != 'generate_appcast': |
| return |
| prefix = command[command.index('--download-url-prefix') + 1] |
| @@ -174,6 +178,9 @@ class PublicationBoundaryTests(unittest.TestCase): |
| def pushes(self): |
| return [event for event in self.events if event[:2] == ('git', 'push')] |
| + def feed_pushes(self): |
| + return [event for event in self.pushes() if event[-1] == 'HEAD:refs/heads/main'] |
| + |
| def test_manifest_cannot_claim_a_newer_build_than_the_signed_archive(self): |
| self.manifest['build'] = '4' |
| self.write_manifest() |
| @@ -208,13 +215,15 @@ class PublicationBoundaryTests(unittest.TestCase): |
| self.assertEqual(plan['build'], '3') |
| self.assertFalse((self.repo / 'meet/releases').exists()) |
| self.assertEqual(self.feed.read_text(), self.original_feed) |
| - self.assertFalse(any(event[0] == 'git' and event[1] in ('fetch', 'add', 'commit', 'push') for event in self.events)) |
| + self.assertFalse(any(event[0] == 'git' and event[1] in ('fetch', 'tag', 'add', 'commit', 'push') for event in self.events)) |
| + self.assertNotIn(('amadan', 'release', 'create'), self.events) |
| + self.assertEqual(plan['download'], 'https://amadan.net/oficina/native-releases/-/releases/meet-3/meet-3.zip') |
| def test_failed_public_download_leaves_existing_feed_untouched(self): |
| self.download_error = ValueError('download checksum mismatch') |
| with self.assertRaisesRegex(ValueError, 'checksum mismatch'): |
| self.publish() |
| - self.assertEqual(len(self.pushes()), 1) |
| + self.assertEqual(self.feed_pushes(), []) |
| self.assertNotIn(('generate_appcast',), self.events) |
| self.assertEqual(self.feed.read_text(), self.original_feed) |
| @@ -222,18 +231,41 @@ class PublicationBoundaryTests(unittest.TestCase): |
| self.generated_build = '4' |
| with self.assertRaisesRegex(ValueError, 'build does not match'): |
| self.publish() |
| - self.assertEqual(len(self.pushes()), 1) |
| + self.assertEqual(self.feed_pushes(), []) |
| self.assertEqual(self.feed.read_text(), self.original_feed) |
| def test_archive_is_publicly_verified_before_feed_publication(self): |
| self.publish() |
| - pushes = [index for index, event in enumerate(self.events) if event[:2] == ('git', 'push')] |
| - self.assertEqual(len(pushes), 2) |
| + tag = self.events.index(('git', 'push', 'origin', 'refs/tags/meet-3')) |
| + uploaded = self.events.index(('amadan', 'release', 'create')) |
| verified = self.events.index(('verify_download',)) |
| generated = self.events.index(('generate_appcast',)) |
| - self.assertLess(pushes[0], verified) |
| + feed = self.events.index(('git', 'push', 'origin', 'HEAD:refs/heads/main')) |
| + self.assertLess(tag, uploaded) |
| + self.assertLess(uploaded, verified) |
| self.assertLess(verified, generated) |
| - self.assertLess(generated, pushes[1]) |
| + self.assertLess(generated, feed) |
| + |
| + def test_archive_is_never_committed_to_git(self): |
| + # native-releases#1: zips in Git history took amadan.net down. |
| + self.publish() |
| + added = [event for event in self.events if event[:2] == ('git', 'add')] |
| + self.assertEqual(added, [('git', 'add', '--', 'meet/appcast.xml')]) |
| + self.assertFalse((self.repo / 'meet/releases').exists()) |
| + |
| + def test_rerun_accepts_refused_upload_only_when_asset_matches(self): |
| + # An asset cannot be replaced, so a rerun's upload fails; the public bytes decide. |
| + self.upload_returncode = 1 |
| + self.assertEqual(self.publish()['build'], '3') |
| + self.assertEqual(len(self.feed_pushes()), 1) |
| + |
| + def test_failed_upload_without_a_matching_asset_publishes_nothing(self): |
| + self.upload_returncode = 1 |
| + self.download_error = OSError('HTTP Error 404: Not Found') |
| + with self.assertRaises(OSError): |
| + self.publish() |
| + self.assertEqual(self.feed_pushes(), []) |
| + self.assertEqual(self.feed.read_text(), self.original_feed) |
| if __name__ == '__main__': |