diff --git a/signingscript/src/signingscript/sign.py b/signingscript/src/signingscript/sign.py index 7098644cd..e2a625565 100644 --- a/signingscript/src/signingscript/sign.py +++ b/signingscript/src/signingscript/sign.py @@ -1706,13 +1706,50 @@ async def sign_rpm_pkg(context, path, fmt, **kwargs): return path +async def _notarize_wait_staple_batch(context, paths, attempts): + """Serial notarize-submit, notary-wait, then staple for a list of paths. + + No-op on empty list. Each step wraps the rcodesign call in retry_async + with the given attempt budget, raising RCodesignError on exhaustion. + """ + if not paths: + return + submissions_map = {} + for path in paths: + submissions_map[path] = await retry_async( + func=rcodesign_notarize, + args=(path, context.apple_credentials_path), + attempts=attempts, + retry_exceptions=RCodesignError, + ) + for path, submission_id in submissions_map.items(): + await retry_async( + func=rcodesign_notary_wait, + args=(submission_id, context.apple_credentials_path), + attempts=attempts, + retry_exceptions=RCodesignError, + ) + for path in submissions_map.keys(): + await retry_async( + func=rcodesign_staple, + args=[path], + attempts=attempts, + retry_exceptions=RCodesignError, + ) + + @time_async_function async def apple_notarize_stacked(context, filelist_dict): """ Notarizes multiple packages using rcodesign. - Submits everything before polling for status. + + Fully notarizes and staples .pkg paths first, then probes each .app with + a short-retry staple attempt. .apps that fail the probe fall back to the + full notarize/wait/staple pipeline. This avoids redundant notarization + requests for .apps that become valid transitively via their parent .pkg. """ ATTEMPTS = 5 + STAPLE_PROBE_RETRY_KWARGS = {"attempts": 3, "sleeptime_kwargs": {"delay_factor": 15}} relpath_index_map = {} paths_to_notarize = [] @@ -1742,34 +1779,34 @@ async def apple_notarize_stacked(context, filelist_dict): else: raise SigningScriptError(f"Unsupported file extension: {extension} for file {relpath}") - # notarization submissions map (path -> submission_id) - submissions_map = {} - # Submit to notarization one by one - for path in paths_to_notarize: - submissions_map[path] = await retry_async( - func=rcodesign_notarize, - args=(path, context.apple_credentials_path), - attempts=ATTEMPTS, - retry_exceptions=RCodesignError, - ) - - # Notary wait all files - for path, submission_id in submissions_map.items(): - await retry_async( - func=rcodesign_notary_wait, - args=(submission_id, context.apple_credentials_path), - attempts=ATTEMPTS, - retry_exceptions=RCodesignError, - ) - - # Staple files - for path in submissions_map.keys(): - await retry_async( - func=rcodesign_staple, - args=[path], - attempts=ATTEMPTS, - retry_exceptions=RCodesignError, - ) + pkg_paths = [p for p in paths_to_notarize if p.endswith(".pkg")] + app_paths = [p for p in paths_to_notarize if p.endswith(".app")] + + # Phase A: full notarize/wait/staple pipeline for every .pkg + await _notarize_wait_staple_batch(context, pkg_paths, ATTEMPTS) + + # Phase B: staple probe per .app; success means the .app was transitively + # validated by its parent .pkg in Phase A. When no .pkg ran in Phase A, + # there's no parent ticket to wait for, so probe just once (no retry). + # The single-attempt probe also covers the split-task case: .apps and + # .pkgs can be notarized in separate tasks with an app->pkg dependency + # in CI, so by the time the .app task runs its parent .pkg is already + # stapled and the probe succeeds on the first try. + probe_kwargs = STAPLE_PROBE_RETRY_KWARGS if pkg_paths else {"attempts": 1} + apps_needing_notarization = [] + for app_path in app_paths: + try: + await retry_async( + func=rcodesign_staple, + args=[app_path], + retry_exceptions=RCodesignError, + **probe_kwargs, + ) + except RCodesignError: + apps_needing_notarization.append(app_path) + + # Phase C: full pipeline fallback for .apps that failed the probe + await _notarize_wait_staple_batch(context, apps_needing_notarization, ATTEMPTS) # Wrap up stapled_files = [] diff --git a/signingscript/tests/test_sign.py b/signingscript/tests/test_sign.py index 543991c62..d3b371023 100644 --- a/signingscript/tests/test_sign.py +++ b/signingscript/tests/test_sign.py @@ -1487,10 +1487,102 @@ async def test_apple_notarize_stacked(mocker, context): "/app2.pkg": {"full_path": "/app2.pkg", "formats": ["apple_notarize_stacked"]}, }, ) - # one for each file format + # Phase A notarizes/waits the 2 .pkgs; the 1 .app is transitively validated + # by its parent .pkg, so only its Phase B staple probe runs (no notarize/wait). + assert notarize.await_count == 2 + assert wait.await_count == 2 + assert staple.await_count == 3 + + +@pytest.mark.asyncio +async def test_apple_notarize_stacked_probe_fallback(mocker, context): + """.app staple probe fails -> fall back to full notarize/wait/staple.""" + + async def no_retry(func=None, args=(), kwargs=None, attempts=1, retry_exceptions=Exception, **_): + kwargs = kwargs or {} + return await func(*args, **kwargs) + + mocker.patch.object(sign, "retry_async", new=no_retry) + + notarize = mock.AsyncMock() + mocker.patch.object(sign, "rcodesign_notarize", notarize) + wait = mock.AsyncMock() + mocker.patch.object(sign, "rcodesign_notary_wait", wait) + + app_probe_failures = {"remaining": 1} + + async def staple_side_effect(path): + if path.endswith(".app") and app_probe_failures["remaining"] > 0: + app_probe_failures["remaining"] -= 1 + raise sign.RCodesignError("simulated probe failure") + return None + + staple = mock.AsyncMock(side_effect=staple_side_effect) + mocker.patch.object(sign, "rcodesign_staple", staple) + + mocker.patch.object(sign, "_extract_tarfile", noop_async) + mocker.patch.object(sign, "_create_tarfile", noop_async) + mocker.patch.object(sign.os, "listdir", lambda *_: ["/foo.pkg", "/baz.app", "/foobar"]) + mocker.patch.object(sign.os, "walk", lambda *_: [("/", None, ["foo.pkg", "baz.app"])]) + mocker.patch.object(sign.shutil, "rmtree", noop_sync) + mocker.patch.object(sign.utils, "mkdir", noop_sync) + mocker.patch.object(sign.utils, "copy_to_dir", noop_sync) + + await sign.apple_notarize_stacked( + context, + { + "/app.tar.gz": {"full_path": "/app.tar.gz", "formats": ["apple_notarize_stacked"]}, + "/app2.pkg": {"full_path": "/app2.pkg", "formats": ["apple_notarize_stacked"]}, + }, + ) + # Phase A: 2 .pkgs notarized + waited + stapled. + # Phase B: 1 probe attempt on the .app (raises). + # Phase C: fallback notarize + wait + staple for that .app. assert notarize.await_count == 3 assert wait.await_count == 3 - assert staple.await_count == 3 + assert staple.await_count == 4 + fallback_notarize = [c for c in notarize.await_args_list if c.args[0].endswith(".app")] + assert len(fallback_notarize) == 1 + + +@pytest.mark.asyncio +async def test_apple_notarize_stacked_no_pkg_single_probe(mocker, context): + """When no .pkg is in the batch, .apps get a single-attempt probe, then Phase C.""" + notarize = mock.AsyncMock() + mocker.patch.object(sign, "rcodesign_notarize", notarize) + wait = mock.AsyncMock() + mocker.patch.object(sign, "rcodesign_notary_wait", wait) + + # Probe fails once; fallback staple in Phase C succeeds. + app_probe_failures = {"remaining": 1} + + async def staple_side_effect(path): + if path.endswith(".app") and app_probe_failures["remaining"] > 0: + app_probe_failures["remaining"] -= 1 + raise sign.RCodesignError("simulated probe failure") + return None + + staple = mock.AsyncMock(side_effect=staple_side_effect) + mocker.patch.object(sign, "rcodesign_staple", staple) + + mocker.patch.object(sign, "_extract_tarfile", noop_async) + mocker.patch.object(sign, "_create_tarfile", noop_async) + # tar.gz extracts to a .app only (no .pkg alongside) + mocker.patch.object(sign.os, "listdir", lambda *_: ["/baz.app", "/foobar"]) + mocker.patch.object(sign.os, "walk", lambda *_: [("/", None, ["baz.app"])]) + mocker.patch.object(sign.shutil, "rmtree", noop_sync) + mocker.patch.object(sign.utils, "mkdir", noop_sync) + mocker.patch.object(sign.utils, "copy_to_dir", noop_sync) + + await sign.apple_notarize_stacked( + context, + {"/app.tar.gz": {"full_path": "/app.tar.gz", "formats": ["apple_notarize_stacked"]}}, + ) + # No .pkgs -> Phase A empty. Phase B probes once (fails, no retry). + # Phase C notarizes/waits/staples the .app. + assert notarize.await_count == 1 + assert wait.await_count == 1 + assert staple.await_count == 2 # 1 probe (raises) + 1 Phase C staple (succeeds) @pytest.mark.asyncio