Merge branch 'fix/release-assets' into main
Merge branch 'fix/release-assets' into main
3 files changed,
+133
−43
docs/mac-updates.md+48 −19tools/publish_mac_update.py+24 −16tools/tests/test_mac_updates.py+61 −8
CI failed — run details
CI log
=== test === swift test 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' [0/1] Planning build 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
landed from fix/release-assets
diff --git a/docs/mac-updates.md b/docs/mac-updates.md| index 7cf5269..393b3ca 100644 |
| --- a/docs/mac-updates.md |
| +++ b/docs/mac-updates.md |
| @@ -89,27 +89,55 @@ 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 before the feed commit, 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. If it |
| +stopped while writing the feed, before its commit, discard the half-written feed |
| +and rerun. The publisher only starts on a clean checkout, so this removes nothing |
| +else: `git reset --hard HEAD`, then `git clean -f -- <app>/appcast.xml` for an |
| +app's first feed. If only the final feed push failed, the feed commit is already |
| +verified. Push it with `git push origin HEAD:refs/heads/main`, and do not rerun, |
| +because a rerun refuses a build the local feed already lists. |
| + |
| +The hub does not yet make an asset's bytes atomic against two simultaneous |
| +uploads of the same name. Only one publisher may run for a build at a time. |
| +Sparkle still checks each download's EdDSA signature, so replaced bytes fail |
| +to install rather than installing. |
| + |
| +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 +147,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..469c584 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) |
| @@ -142,11 +149,12 @@ def publish(release, config_path, repo, tools, do_publish=False): |
| 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, 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..f82bb8e 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,9 @@ class PublicationBoundaryTests(unittest.TestCase): |
| self.events = [] |
| self.generated_build = '3' |
| self.download_error = None |
| + self.upload_returncode = 0 |
| + self.existing_tag = '' |
| + self.downloads = [] |
| 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 +156,15 @@ class PublicationBoundaryTests(unittest.TestCase): |
| return 'main' |
| if args[0] == 'rev-parse': |
| return 'a' * 40 |
| + if args[:2] == ('tag', '--list'): |
| + return self.existing_tag |
| return '' |
| 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] |
| @@ -164,6 +172,7 @@ class PublicationBoundaryTests(unittest.TestCase): |
| output.write_text(f'<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle"><channel><item><sparkle:version>{self.generated_build}</sparkle:version><enclosure url="{prefix}{self.archive.name}" sparkle:edSignature="test-signature" length="{self.manifest["length"]}"/></item></channel></rss>') |
| def download(self, *args): |
| + self.downloads.append(args) |
| self.events.append(('verify_download',)) |
| if self.download_error: |
| raise self.download_error |
| @@ -174,6 +183,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 +220,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 +236,57 @@ 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_verification_checks_the_release_asset_against_the_signed_bytes(self): |
| + self.publish() |
| + self.assertEqual(self.downloads, [('https://amadan.net/oficina/native-releases/-/releases/meet-3/meet-3.zip', self.manifest['length'], self.manifest['sha256'])]) |
| + |
| + def test_first_release_of_an_app_creates_its_feed(self): |
| + self.feed.unlink() |
| + self.feed.parent.rmdir() |
| + self.publish() |
| + self.assertIn('<sparkle:version>3</sparkle:version>', self.feed.read_text()) |
| + |
| + def test_rerun_reuses_the_tag_an_earlier_run_pushed(self): |
| + self.existing_tag = 'meet-3' |
| + self.publish() |
| + self.assertFalse(any(event[:2] == ('git', 'tag') and event[2] != '--list' for event in self.events)) |
| + self.assertIn(('git', 'push', 'origin', 'refs/tags/meet-3'), self.events) |
| + |
| + 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__': |