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>
3 files changed,
+37
−6
docs/mac-updates.md+14 −5tools/publish_mac_update.py+1 −0tools/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 |