rastrillo / native Public

Create an app's first feed, and say exactly what a rerun recovers

Review of 903f3cd: dropping the archive's mkdir also stopped creating
<app>/ for an app with no feed yet, so its first publication failed after
uploading. The recovery notes promised a rerun fixes any stop; it does
not once the feed is committed. They now give the step for each case and
say the hub's asset immutability is not atomic against two simultaneous
uploads.

Tests: first feed, tag reuse on rerun, and the exact URL, length and
digest verified.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev dd2c7fcbfcf7c3d3a306faec0e3cd398acab0907 parent 903f3cd
3 files changed, +37 −6
  • docs/mac-updates.md +14 −5
  • tools/publish_mac_update.py +1 −0
  • tools/tests/test_mac_updates.py +22 −1

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'
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 47c33a6..83a1b54 100644
--- a/docs/mac-updates.md
+++ b/docs/mac-updates.md
@@ -118,11 +118,20 @@ upload sizes bounded.
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.
+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, restore the feed with `git checkout -- <app>/appcast.xml`
+and rerun. 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
diff --git a/tools/publish_mac_update.py b/tools/publish_mac_update.py
index 58590c7..469c584 100644
--- a/tools/publish_mac_update.py
+++ b/tools/publish_mac_update.py
@@ -149,6 +149,7 @@ 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")
diff --git a/tools/tests/test_mac_updates.py b/tools/tests/test_mac_updates.py
index 76b7edd..f82bb8e 100644
--- a/tools/tests/test_mac_updates.py
+++ b/tools/tests/test_mac_updates.py
@@ -132,6 +132,8 @@ class PublicationBoundaryTests(unittest.TestCase):
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()
@@ -154,7 +156,9 @@ class PublicationBoundaryTests(unittest.TestCase):
return 'main'
if args[0] == 'rev-parse':
return 'a' * 40
- return '' # also "git tag --list": no tag from an earlier run
+ if args[:2] == ('tag', '--list'):
+ return self.existing_tag
+ return ''
def tool(self, command, **kwargs):
tool = Path(command[0]).name
@@ -168,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
@@ -253,6 +258,22 @@ class PublicationBoundaryTests(unittest.TestCase):
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