authorclover caruso <git@paperclover.net> 2026-10-15 03:09:48-07:00
committerclover caruso <git@paperclover.net> 2026-10-15 03:43:27-07:00
log4abc252b6b516d1b4d937582c1ae2b58693807a2
tree26a1e3ad194eb68a8e3ac3f2bd0420391919e62f
parente3d4066dc407255c595ad64f3ec4aaed85e64628
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

fix(release): keep retrying a lost staple connection

Stapling now retries a lost connection to the ticket service up to five times with doubling waits, so a brief outage no longer aborts a release after every platform is notarized. Refs #142 Assisted-by: claude-opus-5.5

2 files changed, 12 insertions(+), 8 deletions(-)

tools/release.py+3-2
...@@ -207,7 +207,7 @@ def notary():...@@ -207,7 +207,7 @@ def notary():
207207
208208
209def staple(bundle):209def staple(bundle):
210 for attempt in range(2):210 for attempt in range(5):
211 with tempfile.TemporaryFile(mode='w+') as log:211 with tempfile.TemporaryFile(mode='w+') as log:
212 try:212 try:
213 try:213 try:
...@@ -219,9 +219,10 @@ def staple(bundle):...@@ -219,9 +219,10 @@ def staple(bundle):
219 print(diagnostic, end='', flush=True)219 print(diagnostic, end='', flush=True)
220 except subprocess.CalledProcessError as error:220 except subprocess.CalledProcessError as error:
221 # EX_NOHOST alone also means the bundle has never been notarized.221 # EX_NOHOST alone also means the bundle has never been notarized.
222 if attempt or error.returncode != 68 or not re.search(222 if attempt == 4 or error.returncode != 68 or not re.search(
223 r'^error is Error Domain=NSURLErrorDomain Code=-1005(?:\s|$)', diagnostic, re.M):223 r'^error is Error Domain=NSURLErrorDomain Code=-1005(?:\s|$)', diagnostic, re.M):
224 raise224 raise
225 time.sleep(10 * 2 ** attempt)
225 else:226 else:
226 return227 return
227228
tools/test_release.py+9-6
...@@ -94,9 +94,9 @@ class ReleaseTest(unittest.TestCase):...@@ -94,9 +94,9 @@ class ReleaseTest(unittest.TestCase):
94 self.assertEqual(error.stderr, b'diagnostic')94 self.assertEqual(error.stderr, b'diagnostic')
95 self.assertIn('codesign --verify Snowbound.app', printed.getvalue())95 self.assertIn('codesign --verify Snowbound.app', printed.getvalue())
9696
97 def test_staple_retries_only_a_lost_connection_and_propagates_second_failure(self):97 def test_staple_retries_only_a_lost_connection_and_propagates_last_failure(self):
98 network = 'error is Error Domain=NSURLErrorDomain Code=-1005 "The network connection was lost."\n'98 network = 'error is Error Domain=NSURLErrorDomain Code=-1005 "The network connection was lost."\n'
99 for failures in ([(68, network)], [(68, network), (68, network)],99 for failures in ([(68, network)], [(68, network)] * 4, [(68, network)] * 5,
100 [(68, 'The bundle has not been notarized.\n')],100 [(68, 'The bundle has not been notarized.\n')],
101 [(77, network)], [(68, network.replace('-1005', '-10050'))]):101 [(77, network)], [(68, network.replace('-1005', '-10050'))]):
102 with self.subTest(failures=failures), tempfile.TemporaryDirectory() as folder:102 with self.subTest(failures=failures), tempfile.TemporaryDirectory() as folder:
...@@ -114,15 +114,18 @@ class ReleaseTest(unittest.TestCase):...@@ -114,15 +114,18 @@ class ReleaseTest(unittest.TestCase):
114 kwargs['stdout'].write('The staple and validate action worked!\n')114 kwargs['stdout'].write('The staple and validate action worked!\n')
115115
116 printed = io.StringIO()116 printed = io.StringIO()
117 with patch.dict(release['staple'].__globals__, {'run': failing_tool}), redirect_stdout(printed):117 sleep = Mock()
118 if len(failures) == 1 and failures[0] == (68, network):118 with patch.dict(release['staple'].__globals__, {'run': failing_tool}), \
119 patch.object(release['time'], 'sleep', sleep), redirect_stdout(printed):
120 if failures[0] == (68, network) and len(failures) < 5:
119 self.assertIsNone(release['staple'](Path(folder) / 'Snowbound.app'))121 self.assertIsNone(release['staple'](Path(folder) / 'Snowbound.app'))
120 else:122 else:
121 with self.assertRaises(subprocess.CalledProcessError) as raised:123 with self.assertRaises(subprocess.CalledProcessError) as raised:
122 release['staple'](Path(folder) / 'Snowbound.app')124 release['staple'](Path(folder) / 'Snowbound.app')
123 self.assertIs(raised.exception, errors[-1])125 self.assertIs(raised.exception, errors[-1])
124 expected = 2 if failures[0] == (68, network) else 1126 retried = failures[0] == (68, network)
125 self.assertEqual(len(calls), expected)127 self.assertEqual(len(calls), min(len(failures) + 1, 5) if retried else 1)
128 self.assertEqual([wait for (wait,), _ in sleep.call_args_list], [10, 20, 40, 80][:len(calls) - 1])
126 self.assertEqual(printed.getvalue(), ''.join(text for _, text in failures) +129 self.assertEqual(printed.getvalue(), ''.join(text for _, text in failures) +
127 ('The staple and validate action worked!\n' if len(errors) < len(calls) else ''))130 ('The staple and validate action worked!\n' if len(errors) < len(calls) else ''))
128 for command, kwargs in calls:131 for command, kwargs in calls: