Repository: beeware/briefcase

Issue:
maybe briefcase might hypothetically generate an archive invalid for notarization sometimes when using a zip archive for mac app distribution, and should probably use the recommended command to generate them
### Describe the bug

I do not technically know that this bug affects `shutil.make_archive`, but it does affect the operating system's built-in `zip` command, so this *might* not affect briefcase, but reproducing it is weird and difficult, and I *did* hit it with some builds of an app with Encrust, so I am leaving this here regardless.

Anyway, the magic password is:

```
ditto -c -k --sequesterRsrc --keepParent MyApp.app MyApp.app.zip
```

The bug is described here:

https://blog.frostwire.com/2019/08/27/apple-notarization-the-signature-of-the-binary-is-invalid-one-other-reason-not-explained-in-apple-developer-documentation/

The explanation — that the paths themselves are being re-encoded with a different Unicode normalization — does not make sense for my application, because I do not have any non-ASCII in any of the paths in my app. It seems to have something to do with the code signature itself, or some other thing in a resource fork somewhere.  Switching to `ditto` did reliably fix it, though.

### Steps to reproduce

I dunno, maybe try to notarize a zip file who has an embedded signature that doesn't cleanly decode as UTF-8?

### Expected behavior

N/A

### Screenshots

_No response_

### Environment

N/A

### Logs

N/A

### Additional context

This seems heavily dependent on the content of the app being signed and the time at which it is signed, so I have no idea how to submit a reproducer.

Discussion:
@glyph: Worth noting that at least for my part, "just remove zipfile archiving, mac apps always use DMGs"  is an opinionated solution to this which I can respect and would be quite happy with.

Solution patch:
diff --git a/changes/1218.bugfix.rst b/changes/1218.bugfix.rst
new file mode 100644
index 000000000..407164f9e
--- /dev/null
+++ b/changes/1218.bugfix.rst
@@ -0,0 +1 @@
+Briefcase now uses ``ditto`` to archive apps for submission to the notarization service, rather than standard ``zip`` tooling. This ensures that UTF-8 encoding and filesystem resources are preserved.
diff --git a/changes/1472.feature.1.rst b/changes/1472.feature.1.rst
new file mode 100644
index 000000000..c79859e2a
--- /dev/null
+++ b/changes/1472.feature.1.rst
@@ -0,0 +1 @@
+When a macOS notarization attempt fails, Briefcase now displays the cause of the notarization failure.
diff --git a/changes/1472.feature.2.rst b/changes/1472.feature.2.rst
new file mode 100644
index 000000000..4bf7421ed
--- /dev/null
+++ b/changes/1472.feature.2.rst
@@ -0,0 +1 @@
+If macOS app notarization is interrupted, the notarization attempt can now be resumed.
diff --git a/changes/2110.bugfix.rst b/changes/2110.bugfix.rst
new file mode 100644
index 000000000..beae9a627
--- /dev/null
+++ b/changes/2110.bugfix.rst
@@ -0,0 +1 @@
+The hints displayed to the user when an identity has been selected now more accurately reflect the context in which they have been invoked.
diff --git a/changes/2110.feature.rst b/changes/2110.feature.rst
new file mode 100644
index 000000000..a5fcfde9c
--- /dev/null
+++ b/changes/2110.feature.rst
@@ -0,0 +1 @@
+When a verbosity level of 3 (i.e., ``-vvv``) is selected, any tasks that would normally be performed in parallel will now be performed serially.
diff --git a/docs/reference/commands/index.rst b/docs/reference/commands/index.rst
index 7bf3a9cc5..3e5e7f440 100644
--- a/docs/reference/commands/index.rst
+++ b/docs/reference/commands/index.rst
@@ -80,7 +80,8 @@ to increase the verbosity:
   Briefcase operation. This includes the command line, environment, output and return
   value of every tool and command that Briefcase executes as part of a build.
 * ``-vvv`` generates **deep debug** output. This will include the verbose output of
-  all commands that Briefcase has invoked.
+  all commands that Briefcase has invoked. Any commands that Briefcase is able to run
+  in parallel will be run sequentially.
 
 ``-V`` / ``--version``
 ----------------------
diff --git a/docs/reference/platforms/macOS/signing_options.rst b/docs/reference/platforms/macOS/signing_options.rst
index 713da5298..1d9d09a0d 100644
--- a/docs/reference/platforms/macOS/signing_options.rst
+++ b/docs/reference/platforms/macOS/signing_options.rst
@@ -22,3 +22,12 @@ possible to distribute an unsigned installer to others.
 Do not submit the application for notarization. By default, apps will be
 submitted for notarization unless they have been signed with an ad-hoc
 signing identity.
+
+``--resume <submission ID>``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Apple's notarization server can take a long time to respond - in some cases, hours. When
+you submit an app for notarization, the console output of the ``package`` command will
+provide you with a submission ID. If the notarization process is interrupted for any
+reason (including user intervention), you can use this submission ID with the
+``--resume`` option to resume the notarization process for an app.
diff --git a/pyproject.toml b/pyproject.toml
index 387791ba0..33129c2ff 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -80,7 +80,7 @@ dependencies = [
     # provides no basis for determining API stability.
     #
     "cookiecutter >= 2.6.0, < 3.0",
-    "dmgbuild >= 1.6, < 2.0; sys_platform == 'darwin'",
+    "dmgbuild >= 1.6.4, < 2.0; sys_platform == 'darwin'",
     "GitPython >= 3.1, < 4.0",
     "platformdirs >= 2.6, < 5.0",
     "psutil >= 5.9, < 7.0",
diff --git a/src/briefcase/commands/package.py b/src/briefcase/commands/package.py
index 403481907..c9bff8e60 100644
--- a/src/briefcase/commands/package.py
+++ b/src/briefcase/commands/package.py
@@ -36,6 +36,24 @@ def distribution_path(self, app):
         :param app: The app config
         """
 
+    def clean_dist_folder(self, app, **options):
+        """Clean up any existing artefacts in the dist folder.
+
+        Ensures that the dist folder exists, and *doesn't* contain the distribution
+        artefact.
+
+        :param app: The app being packaged
+        :param options: Any additional arguments passed to the package command. This is
+            required because backends that need to *resume* packaging (e.g.,
+            notarization on macOS), need to ignore the "clean" behavior and preserve
+            the existing artefact.
+        """
+        if self.distribution_path(app).exists():
+            self.distribution_path(app).unlink()
+        else:
+            # Ensure the dist folder exists.
+            self.dist_path.mkdir(exist_ok=True)
+
     def package_app(self, app: AppConfig, **options):
         """Package an application.
 
@@ -88,12 +106,9 @@ def _package_app(
         # package in the requested format.
         self.verify_app(app)
 
-        # If the distribution artefact already exists, remove it.
-        if self.distribution_path(app).exists():
-            self.distribution_path(app).unlink()
-        else:
-            # Ensure the dist folder exists.
-            self.dist_path.mkdir(exist_ok=True)
+        # Make sure the dist folder exists, and doesn't contain an existing artefact for
+        # this app.
+        self.clean_dist_folder(app, **options)
 
         # Package the app
         state = self.package_app(app, **full_options(state, options))
diff --git a/src/briefcase/exceptions.py b/src/briefcase/exceptions.py
index e90bcf29b..3039ad216 100644
--- a/src/briefcase/exceptions.py
+++ b/src/briefcase/exceptions.py
@@ -280,5 +280,10 @@ def __init__(self, msg):
         super().__init__(error_code=0, msg=msg)
 
 
+class NotarizationInterrupted(BriefcaseWarning):
+    def __init__(self, msg):
+        super().__init__(error_code=0, msg=msg)
+
+
 class ParseError(Exception):
     """Raised by parser functions to signal parsing was unsuccessful."""
diff --git a/src/briefcase/platforms/macOS/__init__.py b/src/briefcase/platforms/macOS/__init__.py
index 536327b91..1696a6c19 100644
--- a/src/briefcase/platforms/macOS/__init__.py
+++ b/src/briefcase/platforms/macOS/__init__.py
@@ -12,8 +12,12 @@
 
 from briefcase.config import AppConfig
 from briefcase.console import select_option
-from briefcase.exceptions import BriefcaseCommandError
-from briefcase.integrations.subprocess import get_process_id_by_command, is_process_dead
+from briefcase.exceptions import BriefcaseCommandError, NotarizationInterrupted
+from briefcase.integrations.subprocess import (
+    get_process_id_by_command,
+    is_process_dead,
+    json_parser,
+)
 from briefcase.integrations.xcode import XcodeCliTools, get_identities
 from briefcase.platforms.macOS.filters import macOS_log_clean_filter
 from briefcase.platforms.macOS.utils import AppPackagesMergeMixin
@@ -40,9 +44,11 @@ def __init__(self, id="-", name=None):
         if self.id == "-":
             self.team_id = None
             self.name = ADHOC_IDENTITY_NAME
+            self.profile = None
         else:
             self.name = name
             self.team_id = self.team_id_from_name(name)
+            self.profile = f"briefcase-macOS-{self.team_id}"
 
     @classmethod
     def team_id_from_name(cls, name):
@@ -448,6 +454,7 @@ def select_identity(
         self,
         identity: str | None = None,
         app_identity: SigningIdentity | None = None,
+        allow_adhoc: bool = True,
     ) -> SigningIdentity:
         """Get the codesigning identity to use.
 
@@ -463,6 +470,7 @@ def select_identity(
             installer identity. Only non-app signing identities that match the team ID
             for the ``app_identity`` will be presented as options. Omit this value to
             select an app signing identity.
+        :param allow_adhoc: Should the adhoc identities be allowed?
         :returns: The final identity to use
         """
         # Obtain the valid codesigning identities. These are the identities that could
@@ -494,7 +502,8 @@ def select_identity(
             ident_option = "--identity"
 
             # App signing also allows for the adhoc identity
-            identities["-"] = ADHOC_IDENTITY_NAME
+            if allow_adhoc:
+                identities["-"] = ADHOC_IDENTITY_NAME
 
         if identity:
             try:
@@ -521,22 +530,22 @@ def select_identity(
         if identity == "-":
             self.logger.info(
                 f"""
-In the future, you could specify this signing identity by running:
+In future, you could specify this signing identity by using:
 
-    $ briefcase {self.command} macOS --adhoc-sign
+    $ briefcase {self.command} macOS {self.output_format} --adhoc-sign ...
 
 """
             )
         else:
             self.logger.info(
                 f"""
-In the future, you could specify this signing identity by running:
+In future, you could specify this signing identity by using:
 
-    $ briefcase {self.command} macOS {ident_option} {identity}
+    $ briefcase {self.command} macOS {self.output_format} {ident_option} {identity} ...
 
 or
 
-    $ briefcase {self.command} macOS {ident_option} "{identity_name}"
+    $ briefcase {self.command} macOS {self.output_format} {ident_option} "{identity_name}" ...
 
 """
             )
@@ -652,7 +661,9 @@ def sign_app(
         task_id = progress_bar.add_task("Signing App", total=len(sign_targets))
         with progress_bar:
             for group in self.tools.file.sorted_depth_first_groups(sign_targets):
-                with concurrent.futures.ThreadPoolExecutor() as executor:
+                with concurrent.futures.ThreadPoolExecutor(
+                    max_workers=1 if self.logger.verbosity >= 3 else None
+                ) as executor:
                     futures = []
                     for path in group:
                         future = executor.submit(
@@ -687,7 +698,18 @@ def default_packaging_format(self):
         # The default changes depending on whether the app is a console app or a GUI app
         return None
 
-    def distribution_path(self, app):
+    def notarization_path(self, app: AppConfig) -> Path:
+        """The file that is submitted for notarization."""
+        if app.packaging_format == "zip":
+            # Notarization for bare .app's is applied to the binary, not the
+            # distribution artefact, with the distribution artefact being
+            # created after notarization has completed.
+            return self.binary_path(app)
+        else:
+            return self.distribution_path(app)
+
+    def distribution_path(self, app: AppConfig) -> Path:
+        """The path to the final distribution artefact."""
         if app.packaging_format == "zip":
             return self.dist_path / f"{app.formal_name}-{app.version}.app.zip"
         elif app.packaging_format == "pkg":
@@ -726,6 +748,12 @@ def add_options(self, parser):
             const=False,
             help="Disable notarization for the app",
         )
+        parser.add_argument(
+            "--resume",
+            dest="submission_id",
+            help="The notarization submission ID to resume",
+            required=False,
+        )
 
     def verify_tools(self):
         # Require the Xcode command line tools.
@@ -755,48 +783,145 @@ def verify_app(self, app):
         elif app.packaging_format is None:
             app.packaging_format = "dmg"
 
-    def notarize(self, filename, identity: SigningIdentity):
-        """Notarize a file.
+    def clean_dist_folder(self, app, **options):
+        """Clean up any existing artefacts in the dist folder.
+
+        If we are resuming a notarization session verify that the artefact exists,
+        but *do not* delete it.
+
+        :param app: The app being packaged.
+        :param submission_id: The notarization submission being resumed.
+        :param options: Any additional arguments passed to the package command.
+        """
+        if options.get("submission_id"):
+            if not self.notarization_path(app).exists():
+                raise BriefcaseCommandError(
+                    "Notarization cannot be resumed, as the notarization artefact "
+                    "associated with this app "
+                    f"({self.notarization_path(app).relative_to(self.base_path)}) "
+                    "does not exist."
+                )
+
+            # If the packaging format is zip, the distribution artefact is created
+            # *after* completion of notarization. If there's an existing distribution
+            # artefact, it must be from a previous notarization/stapling attempt.
+            if app.packaging_format == "zip":
+                super().clean_dist_folder(app, **options)
+        else:
+            super().clean_dist_folder(app, **options)
+
+    def ditto_archive(
+        self,
+        app_filename: Path,
+        archive_filename: Path,
+    ):  # pragma: no-cover-if-not-macos
+        """Create an archive of an app using ditto.
+
+        Although the archive format is ".zip", we can't use standard Zip tools, as they
+        don't preserve UTF-8 encoding on all resources. Instead, we need to use `ditto`,
+        which is provided as part of macOS developer tooling. See
+        https://forums.developer.apple.com/forums/thread/116831 and
+        https://developer.apple.com/library/archive/technotes/tn2206/_index.html for
+        more details.
+
+        :param app_filename: The filename of the app to archive
+        :param archive_filename: The filename of the archive to produce
+        """
+        try:
+            self.tools.subprocess.run(
+                [
+                    "/usr/bin/ditto",
+                    "-c",
+                    "-k",
+                    "--sequesterRsrc",
+                    "--keepParent",
+                    app_filename,
+                    archive_filename,
+                ],
+                check=True,
+            )
+        except subprocess.CalledProcessError as e:
+            raise BriefcaseCommandError(f"Unable to archive {app_filename.name}") from e
+
+    def notarize(
+        self,
+        app: AppConfig,
+        identity: SigningIdentity,
+        installer_identity: SigningIdentity | None = None,
+    ):
+        """Submit a file for notarization, and wait for that notarization to be completed.
+
+        :param app: The app to notarize.
+        :param identity: The code signing used to notarize the app.
+        :param installer_identity: The signing identity to use when signing the
+            installer. Optional unless the packaging format is ``pkg``.
+        """
+        # Determine the arguments that would be needed to reproduce this notarization
+        if installer_identity:
+            identity_args = (
+                f"--identity {identity.id} "
+                f"--installer-identity {installer_identity.id}"
+            )
+            notarization_identity = installer_identity
+        else:
+            identity_args = f"--identity {identity.id}"
+            notarization_identity = identity
+
+        format_args = f"-p {app.packaging_format}"
+
+        # Submit the app for notarization
+        submission_id = self.submit_notarization(app, identity=notarization_identity)
+
+        self.logger.warning(
+            f"""
+Briefcase will now wait for Apple to approve the notarization request.
+This can take some time - in some cases, hours.
+
+If notarization is interrupted, you can resume by running:
 
-        Submits the file to Apple for notarization; if successful, staples the
-        notarization result onto the file.
+    briefcase package macOS {self.output_format} {format_args} {identity_args} --resume {submission_id}
+
+"""
+        )
+
+        self.finalize_notarization(
+            app,
+            identity=notarization_identity,
+            submission_id=submission_id,
+        )
+
+    def submit_notarization(self, app, identity: SigningIdentity) -> str:
+        """Submit a file for notarization, returning the ID of the notarizatzion task.
 
         If the file is a .app, it will be archived as a .zip for submission purposes.
 
-        :param filename: The file to notarize.
-        :param identity: The code signing identity to use
+        :param app: The app to notarize.
+        :param identity: The code signing identity to use.
+        :returns: The ID of the notarization task.
         """
+        filename = self.notarization_path(app)
         try:
-            if filename.suffix == ".app":
-                # Archive the app into a zip.
-                with self.input.wait_bar(f"Archiving {filename.name}..."):
-                    archive_filename = filename.parent / "archive.zip"
-                    self.tools.shutil.make_archive(
-                        archive_filename.with_suffix(""),
-                        format="zip",
-                        root_dir=filename.parent,
-                        base_dir=filename.name,
-                    )
-            elif filename.suffix in {".dmg", ".pkg"}:
-                archive_filename = filename
+            if app.packaging_format == "zip":
+                self.logger.info()
+                with self.input.wait_bar(
+                    f"Archiving {filename.name} for notarization..."
+                ):
+                    archive_filename = filename.parent / (filename.name + ".zip")
+                    self.ditto_archive(filename, archive_filename)
             else:
                 archive_filename = filename
-                raise RuntimeError(
-                    f"Don't know how to notarize a file of type {filename.suffix}"
-                )
 
-            profile = f"briefcase-macOS-{identity.team_id}"
-            submitted = False
+            submission_id = None
             store_credentials = False
-            while not submitted:
+            while not submission_id:
                 if store_credentials:
                     if not self.input.enabled:
                         raise BriefcaseCommandError(
                             f"""
-The keychain does not contain credentials for the profile {profile}.
+The keychain does not contain credentials for the profile {identity.profile}.
 You can store these credentials by invoking:
 
-    $ xcrun notarytool store-credentials --team-id {identity.team_id} profile
+    $ xcrun notarytool store-credentials --team-id {identity.team_id} {identity.profile}
 
 """
                         )
@@ -827,7 +952,7 @@ def notarize(self, filename, identity: SigningIdentity):
                                 "store-credentials",
                                 "--team-id",
                                 identity.team_id,
-                                profile,
+                                identity.profile,
                             ],
                             check=True,
                             stream_output=False,  # Command reads from stdin.
@@ -840,19 +965,21 @@ def notarize(self, filename, identity: SigningIdentity):
                 # Attempt the notarization
                 try:
                     self.logger.info()
-                    self.tools.subprocess.run(
-                        [
-                            "xcrun",
-                            "notarytool",
-                            "submit",
-                            archive_filename,
-                            "--keychain-profile",
-                            profile,
-                            "--wait",
-                        ],
-                        check=True,
-                    )
-                    submitted = True
+                    with self.input.wait_bar("Submitting app for notarization..."):
+                        submission = self.tools.subprocess.parse_output(
+                            json_parser,
+                            [
+                                "xcrun",
+                                "notarytool",
+                                "submit",
+                                archive_filename,
+                                "--keychain-profile",
+                                identity.profile,
+                                "--output-format",
+                                "json",
+                            ],
+                        )
+                        submission_id = submission["id"]
                 except subprocess.CalledProcessError as e:
                     # Error when submitting for notarization.
                     # A return code of 69 (nice) indicates an issue with the
@@ -863,37 +990,150 @@ def notarize(self, filename, identity: SigningIdentity):
                         store_credentials = True
                     else:
                         raise BriefcaseCommandError(
-                            f"""\
-Unable to submit {filename.relative_to(self.base_path)} for notarization.
-To find the cause of this failure, get the submission ID by running:
-
-    xcrun notarytool history
-
-Then run:
-
-    xcrun notarytool log <submission-id>
-
-to generate a full log of the error.
-"""
+                            f"Unable to submit {filename.relative_to(self.base_path)} for notarization."
                         ) from e
         finally:
-            # Clean up house; we don't need the archive anymore.
+            # If we're using .zip packaging, the archive is temporary and isn't used for
+            # distribution, so we can clean up.
             if archive_filename != filename:
                 self.tools.os.unlink(archive_filename)
 
+        return submission_id
+
+    def validate_submission_id(
+        self,
+        app: AppConfig,
+        identity: SigningIdentity,
+        submission_id: str,
+    ):
+        with self.input.wait_bar("Determining validity of submission ID..."):
+            try:
+                response = self.tools.subprocess.parse_output(
+                    json_parser,
+                    [
+                        "xcrun",
+                        "notarytool",
+                        "history",
+                        "--keychain-profile",
+                        identity.profile,
+                        "--output-format",
+                        "json",
+                    ],
+                )
+
+                id_matches = [
+                    submission
+                    for submission in response["history"]
+                    if submission["id"] == submission_id
+                ]
+
+                expected_filename = id_matches[0]["name"]
+                # .app files are zipped for notarization, but the app itself is
+                # notarized; strip the .zip suffix for filename matching purposes.
+                if expected_filename.endswith(".zip"):
+                    expected_filename = expected_filename[:-4]
+
+                if expected_filename != self.notarization_path(app).name:
+                    raise BriefcaseCommandError(
+                        f"{submission_id} is not a submission ID for this project. "
+                        f"It notarizes a file named {expected_filename}"
+                    )
+            except IndexError:
+                raise BriefcaseCommandError(
+                    f"{submission_id} is not a known submission ID for this identity."
+                )
+            except subprocess.CalledProcessError:
+                raise BriefcaseCommandError(
+                    "Unable to invoke notarytool to determine validity of submission ID.\n"
+                    "Are you sure this is the identity that was used to notarize the app?"
+                )
+
+    def finalize_notarization(
+        self,
+        app: AppConfig,
+        identity: SigningIdentity,
+        submission_id: str,
+    ):
+        """Finalize a notarization task.
+
+        Polls to check the current notarization status; once notarization approval is
+        received, the notarization is stapled onto the distribution artefact.
+
+        :param app: The app to notarize.
+        :param identity: The code signing identity to use.
+        :param submission_id: The submission ID of the notarization task to finalize.
+        """
         try:
-            self.logger.info()
-            self.logger.info(
-                f"Stapling notarization onto {filename.relative_to(self.base_path)}..."
-            )
-            self.tools.subprocess.run(
-                ["xcrun", "stapler", "staple", filename],
-                check=True,
-            )
-        except subprocess.CalledProcessError:
-            raise BriefcaseCommandError(
-                f"Unable to staple notarization onto {filename.relative_to(self.base_path)}."
-            )
+            with self.input.wait_bar("Waiting for notarization acceptance..."):
+                accepted = False
+                while not accepted:
+                    try:
+                        response = self.tools.subprocess.parse_output(
+                            json_parser,
+                            [
+                                "xcrun",
+                                "notarytool",
+                                "log",
+                                "--keychain-profile",
+                                identity.profile,
+                                submission_id,
+                            ],
+                        )
+
+                        if response["status"] == "Accepted":
+                            accepted = True
+                        elif response["status"] == "Invalid":
+                            summary = response.get(
+                                "statusSummary", "No details provided"
+                            )
+                            raise BriefcaseCommandError(
+                                f"Notarization was rejected: {summary}\n"
+                                + "\n".join(
+                                    f"""
+    * ({issue.get('severity', "?")}) {issue.get('path')} [{issue.get('architecture', "unknown architecture")}]
+      {issue.get('message')}
+      {issue.get('docUrl', '(No additional help available)')}"""
+                                    for issue in response.get("issues", [])
+                                )
+                            )
+                        else:
+                            raise BriefcaseCommandError(
+                                f"Unexpected notarization status: {response['status']}"
+                            )
+                    except subprocess.CalledProcessError as e:
+                        if e.returncode == 69:
+                            # Error code 69 (nice) indicates the server can't give a log
+                            # response for the provided submission ID. We've already
+                            # validated that it's a valid submission ID, so that means
+                            # notarization isn't complete yet. Try again in 10 seconds.
+                            time.sleep(10)
+                        else:
+                            raise BriefcaseCommandError(
+                                "Unknown problem retrieving notarization status."
+                            ) from e
+
+        except KeyboardInterrupt:
+            raise NotarizationInterrupted("Notarization interrupted by user.")
+        else:
+            filename = self.notarization_path(app)
+            try:
+                self.logger.info()
+                self.logger.info(
+                    f"Stapling notarization onto {filename.relative_to(self.base_path)}..."
+                )
+                self.tools.subprocess.run(
+                    ["xcrun", "stapler", "staple", filename],
+                    check=True,
+                )
+            except subprocess.CalledProcessError:
+                raise BriefcaseCommandError(
+                    f"Unable to staple notarization onto {filename.relative_to(self.base_path)}"
+                )
+
+        # Notarization on a zip package is performed on the bare app, so we can't
+        # complete packaging until notarization has completed.
+        if app.packaging_format == "zip":
+            self.finalize_package_zip(app)
 
     def package_app(
         self,
@@ -903,6 +1143,7 @@ def package_app(
         adhoc_sign=False,
         sign_installer=True,
         installer_identity=None,
+        submission_id=None,
         **kwargs,
     ):
         """Package an app bundle.
@@ -921,7 +1162,42 @@ def package_app(
             packaging format is ``pkg``.
         :param installer_identity: The signing identity to use when signing the
             installer. Ignored unless the packaging format is ``pkg``.
+        :param submission_id: The submission ID of the notarization task to resume.
         """
+        if submission_id:
+            # If we're resuming notarization, we *can't* use an adhoc identity,
+            # so don't allow it to be selected.
+            identity = self.select_identity(identity=identity, allow_adhoc=False)
+
+            if app.packaging_format == "pkg":
+                notarization_identity = self.select_identity(
+                    identity=installer_identity,
+                    app_identity=identity,
+                )
+            else:
+                notarization_identity = identity
+
+            self.logger.info(
+                f"Resuming notarization for submission {submission_id}",
+                prefix=app.app_name,
+            )
+
+            self.logger.info()
+            self.validate_submission_id(
+                app,
+                identity=notarization_identity,
+                submission_id=submission_id,
+            )
+
+            self.logger.info()
+            self.finalize_notarization(
+                app,
+                identity=notarization_identity,
+                submission_id=submission_id,
+            )
+            return
+
+        # It's a normal packaging pass.
         self.logger.info("Signing app...", prefix=app.app_name)
         if adhoc_sign:
             identity = SigningIdentity()
@@ -1002,23 +1278,29 @@ def package_zip(
         notarize_app: bool,
         identity: SigningIdentity,
     ):
-        """Package an .app bundle in a zip file."""
-        dist_path: Path = self.distribution_path(app)
+        """Package an .app bundle in a zip file.
 
+        If we're notarizing, this doesn't actually create the distribution artefact.
+        Notarization on a zip package is performed on the bare app, so we can't complete
+        packaging until notarization has completed. We call ``finalize_package_zip()``
+        as part of completing zip notarization.
+        """
         if notarize_app:
             self.logger.info(
                 f"Notarizing app using team ID {identity.team_id}...",
                 prefix=app.app_name,
             )
-            self.notarize(self.binary_path(app), identity=identity)
-
-        with self.input.wait_bar(f"Archiving {dist_path.name}..."):
-            self.tools.shutil.make_archive(
-                dist_path.with_suffix(""),
-                format="zip",
-                root_dir=self.binary_path(app).parent,
-                base_dir=self.binary_path(app).name,
-            )
+            self.notarize(app, identity=identity)
+        else:
+            self.finalize_package_zip(app)
+
+    def finalize_package_zip(self, app: AppConfig):
+        """Finalize the zip packaging process."""
+        # Build the final archive for distribution
+        with self.input.wait_bar(
+            f"Building final distribution archive for {self.binary_path(app).name}..."
+        ):
+            self.ditto_archive(self.binary_path(app), self.distribution_path(app))
 
     def package_pkg(
         self,
@@ -1064,7 +1346,12 @@ def package_pkg(
             installed_app_path = installer_path / "root" / self.binary_path(app).name
             if installed_app_path.exists():
                 self.tools.shutil.rmtree(installed_app_path)
-            self.tools.shutil.copytree(self.binary_path(app), installed_app_path)
+            self.tools.shutil.copytree(
+                self.binary_path(app),
+                installed_app_path,
+                # Ensure that symlinks are preserved in the duplication.
+                symlinks=True,
+            )
 
         components_plist_path = self.bundle_path(app) / "installer/components.plist"
 
@@ -1146,7 +1433,11 @@ def package_pkg(
                 f"Notarizing PKG with team ID {installer_identity.team_id}...",
                 prefix=app.app_name,
             )
-            self.notarize(dist_path, identity=installer_identity)
+            self.notarize(
+                app,
+                identity=identity,
+                installer_identity=installer_identity,
+            )
 
     def package_dmg(
         self,
@@ -1219,4 +1510,4 @@ def package_dmg(
                 f"Notarizing DMG with team ID {identity.team_id}...",
                 prefix=app.app_name,
             )
-            self.notarize(dist_path, identity=identity)
+            self.notarize(app, identity=identity)
diff --git a/src/briefcase/platforms/macOS/utils.py b/src/briefcase/platforms/macOS/utils.py
index d08624619..bda0f5ad0 100644
--- a/src/briefcase/platforms/macOS/utils.py
+++ b/src/briefcase/platforms/macOS/utils.py
@@ -175,7 +175,9 @@ def thin_app_packages(
             self.logger.info(f"Thinning libraries in {app_packages.name}...")
             task_id = progress_bar.add_task("Create fat libraries", total=len(dylibs))
             with progress_bar:
-                with concurrent.futures.ThreadPoolExecutor() as executor:
+                with concurrent.futures.ThreadPoolExecutor(
+                    max_workers=1 if self.logger.verbosity >= 3 else None
+                ) as executor:
                     futures = []
                     for path in dylibs:
                         future = executor.submit(
@@ -252,7 +254,9 @@ def merge_app_packages(
             self.logger.info("Merging libraries...")
             task_id = progress_bar.add_task("Create fat libraries", total=len(dylibs))
             with progress_bar:
-                with concurrent.futures.ThreadPoolExecutor() as executor:
+                with concurrent.futures.ThreadPoolExecutor(
+                    max_workers=1 if self.logger.verbosity >= 3 else None
+                ) as executor:
                     futures = []
                     for relative_path in dylibs:
                         future = executor.submit(


Test patch:
diff --git a/tests/platforms/macOS/app/conftest.py b/tests/platforms/macOS/app/conftest.py
index c627c6bde..64802c64f 100644
--- a/tests/platforms/macOS/app/conftest.py
+++ b/tests/platforms/macOS/app/conftest.py
@@ -94,36 +94,53 @@ def first_app_with_binaries(first_app_templated, first_app_config, tmp_path):
         Path("subfolder/second_dylib.dylib"),
         "other_binary",
     ]:
-        (lib_path / lib).parent.mkdir(parents=True, exist_ok=True)
-        with (lib_path / lib).open("wb") as f:
-            f.write(b"\xCA\xFE\xBA\xBEBinary content here")
+        create_file(
+            lib_path / lib,
+            mode="wb",
+            content=b"\xCA\xFE\xBA\xBEBinary content here",
+        )
 
     # Mach-O file that is executable, with an odd extension
-    with (lib_path / "special.binary").open("wb") as f:
-        f.write(b"\xCA\xFE\xBA\xBEBinary content here")
+    create_file(
+        lib_path / "special.binary",
+        mode="wb",
+        content=b"\xCA\xFE\xBA\xBEBinary content here",
+    )
     os.chmod(lib_path / "special.binary", 0o755)
 
     # An embedded app
-    (lib_path / "Extras.app/Contents/MacOS").mkdir(parents=True, exist_ok=True)
-    with (lib_path / "Extras.app/Contents/MacOS/Extras").open("wb") as f:
-        f.write(b"\xCA\xFE\xBA\xBEBinary content here")
+    create_file(
+        lib_path / "Extras.app/Contents/MacOS/Extras",
+        mode="wb",
+        content=b"\xCA\xFE\xBA\xBEBinary content here",
+    )
 
     # An embedded framework
-    (frameworks_path / "Extras.framework/Resources").mkdir(parents=True, exist_ok=True)
-    with (frameworks_path / "Extras.framework/Resources/extras.dylib").open("wb") as f:
-        f.write(b"\xCA\xFE\xBA\xBEBinary content here")
+    create_plist_file(frameworks_path / "Extras.framework/Resources/Info.plist", {})
+    create_file(
+        frameworks_path / "Extras.framework/Versions/1.2/libs/extras.dylib",
+        mode="wb",
+        content=b"\xCA\xFE\xBA\xBEBinary content here",
+    )
+    (frameworks_path / "Extras.framework/Versions/1.2/Extras").symlink_to(
+        frameworks_path / "Extras.framework/Versions/1.2/libs/extras.dylib"
+    )
+    (frameworks_path / "Extras.framework/Versions/Current").symlink_to(
+        frameworks_path / "Extras.framework/Versions/1.2"
+    )
+    (frameworks_path / "Extras.framework/Extras").symlink_to(
+        frameworks_path / "Extras.framework/Versions/Current/Extras"
+    )
 
     # Make sure there are some files in the bundle that *don't* need to be signed...
-    with (lib_path / "first.other").open("w", encoding="utf-8") as f:
-        f.write("other")
-    with (lib_path / "second.other").open("w", encoding="utf-8") as f:
-        f.write("other")
+    create_file(lib_path / "first.other", "other")
+    create_file(lib_path / "second.other", "other")
 
     # A file that has a Mach-O header, but isn't executable
-    with (lib_path / "unknown.binary").open("wb") as f:
-        f.write(b"\xCA\xFE\xBA\xBEother")
-
-    # A symlink to a Mach-O executable binary file
-    (lib_path / "first_symlink.so").symlink_to(lib_path / "first_so.so")
+    create_file(
+        lib_path / "unknown.binary",
+        mode="wb",
+        content=b"\xCA\xFE\xBA\xBEother",
+    )
 
     return first_app_config
diff --git a/tests/platforms/macOS/app/package/conftest.py b/tests/platforms/macOS/app/package/conftest.py
index 905178fcf..7171bd810 100644
--- a/tests/platforms/macOS/app/package/conftest.py
+++ b/tests/platforms/macOS/app/package/conftest.py
@@ -1,9 +1,9 @@
-import subprocess
 from unittest import mock
 
 import pytest
 
 from briefcase.console import Console, Log
+from briefcase.integrations.subprocess import Subprocess
 from briefcase.platforms.macOS.app import macOSAppPackageCommand
 
 
@@ -21,6 +21,6 @@ def package_command(tmp_path):
     command.sign_file = mock.MagicMock()
     command.notarize = mock.MagicMock()
     command.dmgbuild = mock.MagicMock()
-    command.tools.subprocess = mock.MagicMock(spec=subprocess)
+    command.tools.subprocess = mock.MagicMock(spec=Subprocess)
 
     return command
diff --git a/tests/platforms/macOS/app/package/test_ditto.py b/tests/platforms/macOS/app/package/test_ditto.py
new file mode 100644
index 000000000..b615c59cf
--- /dev/null
+++ b/tests/platforms/macOS/app/package/test_ditto.py
@@ -0,0 +1,103 @@
+import platform
+import subprocess
+from zipfile import ZipFile
+
+import pytest
+
+from briefcase.exceptions import BriefcaseCommandError
+
+
+@pytest.mark.skipif(
+    platform.system() != "Darwin",
+    reason="ditto tests can only be performed on macOS",
+)
+def test_ditto(
+    package_command,
+    first_app_with_binaries,
+    tmp_path,
+):
+    """An app archive can be built with ditto."""
+    app_path = (
+        tmp_path
+        / "base_path"
+        / "build"
+        / "first-app"
+        / "macos"
+        / "app"
+        / "First App.app"
+    )
+    archive_path = tmp_path / "base_path/build/first-app/macos/app/archive.zip"
+
+    # Reconnect the subprocess.run with its live implementation
+    package_command.tools.subprocess.run.side_effect = subprocess.run
+
+    # Build the ditto archive
+    package_command.ditto_archive(app_path, archive_path)
+
+    # The archive contains the app as the only top level element.
+    with ZipFile(archive_path) as archive:
+        assert sorted(archive.namelist()) == [
+            "First App.app/",
+            "First App.app/Contents/",
+            "First App.app/Contents/Frameworks/",
+            "First App.app/Contents/Frameworks/Extras.framework/",
+            "First App.app/Contents/Frameworks/Extras.framework/Extras",
+            "First App.app/Contents/Frameworks/Extras.framework/Resources/",
+            "First App.app/Contents/Frameworks/Extras.framework/Resources/Info.plist",
+            "First App.app/Contents/Frameworks/Extras.framework/Versions/",
+            "First App.app/Contents/Frameworks/Extras.framework/Versions/1.2/",
+            "First App.app/Contents/Frameworks/Extras.framework/Versions/1.2/Extras",
+            "First App.app/Contents/Frameworks/Extras.framework/Versions/1.2/libs/",
+            "First App.app/Contents/Frameworks/Extras.framework/Versions/1.2/libs/extras.dylib",
+            "First App.app/Contents/Frameworks/Extras.framework/Versions/Current",
+            "First App.app/Contents/Info.plist",
+            "First App.app/Contents/MacOS/",
+            "First App.app/Contents/MacOS/First App",
+            "First App.app/Contents/Resources/",
+            "First App.app/Contents/Resources/app_packages/",
+            "First App.app/Contents/Resources/app_packages/Extras.app/",
+            "First App.app/Contents/Resources/app_packages/Extras.app/Contents/",
+            "First App.app/Contents/Resources/app_packages/Extras.app/Contents/MacOS/",
+            "First App.app/Contents/Resources/app_packages/Extras.app/Contents/MacOS/Extras",
+            "First App.app/Contents/Resources/app_packages/first.other",
+            "First App.app/Contents/Resources/app_packages/first_dylib.dylib",
+            "First App.app/Contents/Resources/app_packages/first_so.so",
+            "First App.app/Contents/Resources/app_packages/other_binary",
+            "First App.app/Contents/Resources/app_packages/second.other",
+            "First App.app/Contents/Resources/app_packages/special.binary",
+            "First App.app/Contents/Resources/app_packages/subfolder/",
+            "First App.app/Contents/Resources/app_packages/subfolder/second_dylib.dylib",
+            "First App.app/Contents/Resources/app_packages/subfolder/second_so.so",
+            "First App.app/Contents/Resources/app_packages/unknown.binary",
+        ]
+
+
+@pytest.mark.skipif(
+    platform.system() != "Darwin",
+    reason="ditto tests can only be performed on macOS",
+)
+def test_ditto_failure(
+    package_command,
+    first_app_with_binaries,
+    tmp_path,
+):
+    """If ditto fails, an error is raised."""
+    app_path = (
+        tmp_path
+        / "base_path"
+        / "build"
+        / "first-app"
+        / "macos"
+        / "app"
+        / "First App.app"
+    )
+    archive_path = tmp_path / "base_path/build/first-app/macos/app/archive.zip"
+
+    # Reconnect the subprocess.run with its live implementation
+    package_command.tools.subprocess.run.side_effect = subprocess.CalledProcessError(
+        1, cmd=["ditto"]
+    )
+
+    # Build the ditto archive
+    with pytest.raises(BriefcaseCommandError, match=r"Unable to archive First App.app"):
+        package_command.ditto_archive(app_path, archive_path)
diff --git a/tests/platforms/macOS/app/package/test_notarize.py b/tests/platforms/macOS/app/package/test_notarize.py
index e7a41fb68..e281832e7 100644
--- a/tests/platforms/macOS/app/package/test_notarize.py
+++ b/tests/platforms/macOS/app/package/test_notarize.py
@@ -1,14 +1,14 @@
 import os
 import subprocess
+import uuid
 from unittest import mock
 from unittest.mock import MagicMock
-from zipfile import ZipFile
 
 import pytest
 
 from briefcase.console import Console, Log
-from briefcase.exceptions import BriefcaseCommandError
-from briefcase.integrations.subprocess import Subprocess
+from briefcase.exceptions import BriefcaseCommandError, NotarizationInterrupted
+from briefcase.integrations.subprocess import Subprocess, json_parser
 from briefcase.platforms.macOS.app import macOSAppPackageCommand
 
 
@@ -28,30 +28,42 @@ def package_command(tmp_path):
 
 
 @pytest.fixture
-def first_app_dmg(tmp_path):
+def first_app_zip(first_app_with_binaries, tmp_path):
+    first_app_with_binaries.packaging_format = "zip"
+
+    return first_app_with_binaries
+
+
+@pytest.fixture
+def first_app_dmg(first_app_with_binaries, tmp_path):
+    first_app_with_binaries.packaging_format = "dmg"
+
     dmg_path = tmp_path / "base_path/dist/First App.dmg"
     dmg_path.parent.mkdir(parents=True)
     with dmg_path.open("w", encoding="utf-8") as f:
         f.write("DMG content here")
 
-    return dmg_path
+    return first_app_with_binaries
 
 
 @pytest.fixture
-def first_app_pkg(tmp_path):
+def first_app_pkg(first_app_with_binaries, tmp_path):
+    first_app_with_binaries.packaging_format = "pkg"
+
     dmg_path = tmp_path / "base_path/dist/First App-0.0.1.pkg"
     dmg_path.parent.mkdir(parents=True)
     with dmg_path.open("w", encoding="utf-8") as f:
         f.write("PKG content here")
 
-    return dmg_path
+    return first_app_with_binaries
 
 
 def test_notarize_app(
     package_command,
-    first_app_with_binaries,
+    first_app_zip,
     sekrit_identity,
     tmp_path,
+    sleep_zero,
 ):
     """An app can be notarized."""
     app_path = (
@@ -63,72 +75,100 @@ def test_notarize_app(
         / "app"
         / "First App.app"
     )
-    archive_path = tmp_path / "base_path/build/first-app/macos/app/archive.zip"
-    package_command.notarize(app_path, identity=sekrit_identity)
+    archive_path = tmp_path / "base_path/build/first-app/macos/app/First App.app.zip"
 
-    # As a result of mocking os.unlink, the zip archive won't be
-    # cleaned up, so we can test for its existence, but also
-    # verify that it *would* have been deleted.
-    assert archive_path.exists()
+    # Mock the creation of the ditto archive
+    package_command.ditto_archive = MagicMock()
+
+    # Mock the return values of subprocesses
+    submission_id = str(uuid.uuid4())
+    package_command.tools.subprocess.parse_output.side_effect = [
+        # notarytool submit
+        {"id": submission_id},
+        # notarytool log; 2 failures, then a successful result.
+        subprocess.CalledProcessError(
+            returncode=69,
+            cmd=["xcrun", "notarytool", "log"],
+        ),
+        subprocess.CalledProcessError(
+            returncode=69,
+            cmd=["xcrun", "notarytool", "log"],
+        ),
+        {"status": "Accepted"},
+    ]
+
+    package_command.notarize(first_app_zip, identity=sekrit_identity)
+
+    # As a result of mocking ditto, the zip archive won't *actually* be created;
+    # and as a result of mocking os, it won't *actually* be deleted either - but we can
+    # verify that it *would* have been deleted. ditto will also be called when finalizing,
+    # to create the actual distribution artefact.
+    assert package_command.ditto_archive.mock_calls == [
+        mock.call(app_path, archive_path),
+        mock.call(app_path, tmp_path / "base_path/dist/First App-0.0.1.app.zip"),
+    ]
     package_command.tools.os.unlink.assert_called_with(archive_path)
-    # The archive contains the app as the only top level element.
-    with ZipFile(archive_path) as archive:
-        assert sorted(archive.namelist()) == [
-            "First App.app/",
-            "First App.app/Contents/",
-            "First App.app/Contents/Frameworks/",
-            "First App.app/Contents/Frameworks/Extras.framework/",
-            "First App.app/Contents/Frameworks/Extras.framework/Resources/",
-            "First App.app/Contents/Frameworks/Extras.framework/Resources/extras.dylib",
-            "First App.app/Contents/Info.plist",
-            "First App.app/Contents/MacOS/",
-            "First App.app/Contents/MacOS/First App",
-            "First App.app/Contents/Resources/",
-            "First App.app/Contents/Resources/app_packages/",
-            "First App.app/Contents/Resources/app_packages/Extras.app/",
-            "First App.app/Contents/Resources/app_packages/Extras.app/Contents/",
-            "First App.app/Contents/Resources/app_packages/Extras.app/Contents/MacOS/",
-            "First App.app/Contents/Resources/app_packages/Extras.app/Contents/MacOS/Extras",
-            "First App.app/Contents/Resources/app_packages/first.other",
-            "First App.app/Contents/Resources/app_packages/first_dylib.dylib",
-            "First App.app/Contents/Resources/app_packages/first_so.so",
-            "First App.app/Contents/Resources/app_packages/first_symlink.so",
-            "First App.app/Contents/Resources/app_packages/other_binary",
-            "First App.app/Contents/Resources/app_packages/second.other",
-            "First App.app/Contents/Resources/app_packages/special.binary",
-            "First App.app/Contents/Resources/app_packages/subfolder/",
-            "First App.app/Contents/Resources/app_packages/subfolder/second_dylib.dylib",
-            "First App.app/Contents/Resources/app_packages/subfolder/second_so.so",
-            "First App.app/Contents/Resources/app_packages/unknown.binary",
-        ]
 
-    # The calls to notarize were made
-    package_command.tools.subprocess.run.assert_has_calls(
+    # The calls to notarization tools were made
+    assert package_command.tools.subprocess.parse_output.mock_calls == [
+        # Submit *archive* for notarization
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "submit",
+                archive_path,
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                "--output-format",
+                "json",
+            ],
+        ),
+        # Check status 3 times
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "log",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                submission_id,
+            ],
+        ),
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "log",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                submission_id,
+            ],
+        ),
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "log",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                submission_id,
+            ],
+        ),
+    ]
+
+    package_command.tools.subprocess.run.assert_called_once_with(
         [
-            # Submit *archive* for notarization
-            mock.call(
-                [
-                    "xcrun",
-                    "notarytool",
-                    "submit",
-                    archive_path,
-                    "--keychain-profile",
-                    "briefcase-macOS-DEADBEEF",
-                    "--wait",
-                ],
-                check=True,
-            ),
-            # Staple the result to the *app*
-            mock.call(
-                [
-                    "xcrun",
-                    "stapler",
-                    "staple",
-                    app_path,
-                ],
-                check=True,
-            ),
-        ]
+            "xcrun",
+            "stapler",
+            "staple",
+            app_path,
+        ],
+        check=True,
     )
 
 
@@ -136,41 +176,96 @@ def test_notarize_dmg(
     package_command,
     first_app_dmg,
     sekrit_identity,
+    sleep_zero,
+    tmp_path,
 ):
     """A DMG can be notarized."""
+    # Mock the creation of the ditto archive
+    package_command.ditto_archive = MagicMock()
+
+    # Mock the return values of subprocesses
+    submission_id = str(uuid.uuid4())
+    package_command.tools.subprocess.parse_output.side_effect = [
+        # notarytool submit
+        {"id": submission_id},
+        # notarytool log; 2 failures, then a successful result.
+        subprocess.CalledProcessError(
+            returncode=69,
+            cmd=["xcrun", "notarytool", "log"],
+        ),
+        subprocess.CalledProcessError(
+            returncode=69,
+            cmd=["xcrun", "notarytool", "log"],
+        ),
+        {"status": "Accepted"},
+    ]
 
     package_command.notarize(first_app_dmg, identity=sekrit_identity)
 
-    # The DMG didn't require an archive file, so unlink wasn't invoked.
+    # The DMG didn't require an archive file, so ditto and unlink weren't invoked.
+    package_command.ditto_archive.assert_not_called()
     package_command.tools.os.unlink.assert_not_called()
 
-    # The calls to notarize were made
-    package_command.tools.subprocess.run.assert_has_calls(
+    # The calls to notarization tools were made
+    assert package_command.tools.subprocess.parse_output.mock_calls == [
+        # Submit dmg for notarization
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "submit",
+                tmp_path / "base_path/dist/First App-0.0.1.dmg",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                "--output-format",
+                "json",
+            ],
+        ),
+        # Check status 3 times
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "log",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                submission_id,
+            ],
+        ),
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "log",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                submission_id,
+            ],
+        ),
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "log",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                submission_id,
+            ],
+        ),
+    ]
+
+    package_command.tools.subprocess.run.assert_called_once_with(
         [
-            # Submit for notarization
-            mock.call(
-                [
-                    "xcrun",
-                    "notarytool",
-                    "submit",
-                    first_app_dmg,
-                    "--keychain-profile",
-                    "briefcase-macOS-DEADBEEF",
-                    "--wait",
-                ],
-                check=True,
-            ),
-            # Staple the result
-            mock.call(
-                [
-                    "xcrun",
-                    "stapler",
-                    "staple",
-                    first_app_dmg,
-                ],
-                check=True,
-            ),
-        ]
+            "xcrun",
+            "stapler",
+            "staple",
+            tmp_path / "base_path/dist/First App-0.0.1.dmg",
+        ],
+        check=True,
     )
 
 
@@ -178,138 +273,240 @@ def test_notarize_pkg(
     package_command,
     first_app_pkg,
     sekrit_identity,
+    sekrit_installer_identity,
+    sleep_zero,
+    tmp_path,
 ):
     """A PKG can be notarized."""
+    # Mock the creation of the ditto archive
+    package_command.ditto_archive = MagicMock()
+
+    # Mock the return values of subprocesses
+    submission_id = str(uuid.uuid4())
+    package_command.tools.subprocess.parse_output.side_effect = [
+        # notarytool submit
+        {"id": submission_id},
+        # notarytool log; 2 failures, then a successful result.
+        subprocess.CalledProcessError(
+            returncode=69,
+            cmd=["xcrun", "notarytool", "log"],
+        ),
+        subprocess.CalledProcessError(
+            returncode=69,
+            cmd=["xcrun", "notarytool", "log"],
+        ),
+        {"status": "Accepted"},
+    ]
 
-    package_command.notarize(first_app_pkg, identity=sekrit_identity)
+    package_command.notarize(
+        first_app_pkg,
+        identity=sekrit_identity,
+        installer_identity=sekrit_installer_identity,
+    )
 
-    # The PKG didn't require an archive file, so unlink wasn't invoked.
+    # The PKG didn't require an archive file, so ditto and unlink weren't invoked.
+    package_command.ditto_archive.assert_not_called()
     package_command.tools.os.unlink.assert_not_called()
 
-    # The calls to notarize were made
-    package_command.tools.subprocess.run.assert_has_calls(
+    # The calls to notarization tools were made
+    assert package_command.tools.subprocess.parse_output.mock_calls == [
+        # Submit pkg for notarization
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "submit",
+                tmp_path / "base_path/dist/First App-0.0.1.pkg",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                "--output-format",
+                "json",
+            ],
+        ),
+        # Check status 3 times
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "log",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                submission_id,
+            ],
+        ),
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "log",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                submission_id,
+            ],
+        ),
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "log",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                submission_id,
+            ],
+        ),
+    ]
+
+    package_command.tools.subprocess.run.assert_called_once_with(
         [
-            # Submit for notarization
-            mock.call(
-                [
-                    "xcrun",
-                    "notarytool",
-                    "submit",
-                    first_app_pkg,
-                    "--keychain-profile",
-                    "briefcase-macOS-DEADBEEF",
-                    "--wait",
-                ],
-                check=True,
-            ),
-            # Staple the result
-            mock.call(
-                [
-                    "xcrun",
-                    "stapler",
-                    "staple",
-                    first_app_pkg,
-                ],
-                check=True,
-            ),
-        ]
+            "xcrun",
+            "stapler",
+            "staple",
+            tmp_path / "base_path/dist/First App-0.0.1.pkg",
+        ],
+        check=True,
     )
 
 
-def test_notarize_unknown_format(package_command, sekrit_identity, tmp_path):
-    """Attempting to notarize a file of unknown format raises an error."""
-    pkg_path = tmp_path / "base_path/dist/First App.foo"
-
-    # The notarization call will fail with an error
-    with pytest.raises(
-        RuntimeError,
-        match=r"Don't know how to notarize a file of type .foo",
-    ):
-        package_command.notarize(pkg_path, identity=sekrit_identity)
-
-
 def test_notarize_unknown_credentials(
     package_command,
     first_app_dmg,
     sekrit_identity,
+    sleep_zero,
+    tmp_path,
 ):
     """When notarizing, if credentials haven't been stored, the user will be prompted to
     store them."""
-    # Set up subprocess to fail on the first notarization attempt
-    package_command.tools.subprocess.run.side_effect = [
+    # Mock the creation of the ditto archive
+    package_command.ditto_archive = MagicMock()
+
+    # Mock the return values of subprocesses
+    submission_id = str(uuid.uuid4())
+    package_command.tools.subprocess.parse_output.side_effect = [
+        # notarytool submit failure
         subprocess.CalledProcessError(
             returncode=69,
             cmd=["xcrun", "notarytool", "submit"],
         ),  # Unknown credential failure
-        None,  # Successful credential storage
-        None,  # Successful notarization
-        None,  # Successful stapling
+        # notarytool submit success
+        {"id": submission_id},
+        # notarytool log; 2 failures, then a successful result.
+        subprocess.CalledProcessError(
+            returncode=69,
+            cmd=["xcrun", "notarytool", "log"],
+        ),
+        subprocess.CalledProcessError(
+            returncode=69,
+            cmd=["xcrun", "notarytool", "log"],
+        ),
+        {"status": "Accepted"},
     ]
 
     package_command.notarize(first_app_dmg, identity=sekrit_identity)
 
-    # The DMG didn't require an archive file, so unlink wasn't invoked.
+    # The DMG didn't require an archive file, so ditto and unlink weren't invoked.
+    package_command.ditto_archive.assert_not_called()
     package_command.tools.os.unlink.assert_not_called()
 
-    # The calls to notarize were made
-    package_command.tools.subprocess.run.assert_has_calls(
-        [
-            # Submit for notarization
-            mock.call(
-                [
-                    "xcrun",
-                    "notarytool",
-                    "submit",
-                    first_app_dmg,
-                    "--keychain-profile",
-                    "briefcase-macOS-DEADBEEF",
-                    "--wait",
-                ],
-                check=True,
-            ),
-            # Store credentials in the keychain
-            mock.call(
-                [
-                    "xcrun",
-                    "notarytool",
-                    "store-credentials",
-                    "--team-id",
-                    "DEADBEEF",
-                    "briefcase-macOS-DEADBEEF",
-                ],
-                check=True,
-                stream_output=False,
-            ),
-            # Submit for notarization a second time
-            mock.call(
-                [
-                    "xcrun",
-                    "notarytool",
-                    "submit",
-                    first_app_dmg,
-                    "--keychain-profile",
-                    "briefcase-macOS-DEADBEEF",
-                    "--wait",
-                ],
-                check=True,
-            ),
-            # Staple the result
-            mock.call(
-                [
-                    "xcrun",
-                    "stapler",
-                    "staple",
-                    first_app_dmg,
-                ],
-                check=True,
-            ),
-        ]
-    )
+    # The calls to notarization tools were made
+    assert package_command.tools.subprocess.parse_output.mock_calls == [
+        # Submit dmg for notarization; this attempt will fail
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "submit",
+                tmp_path / "base_path/dist/First App-0.0.1.dmg",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                "--output-format",
+                "json",
+            ],
+        ),
+        # Submit dmg for notarization; this attempt succeeds
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "submit",
+                tmp_path / "base_path/dist/First App-0.0.1.dmg",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                "--output-format",
+                "json",
+            ],
+        ),
+        # Check status 3 times
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "log",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                submission_id,
+            ],
+        ),
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "log",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                submission_id,
+            ],
+        ),
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "log",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                submission_id,
+            ],
+        ),
+    ]
+
+    assert package_command.tools.subprocess.run.mock_calls == [
+        # Submit credentials
+        mock.call(
+            [
+                "xcrun",
+                "notarytool",
+                "store-credentials",
+                "--team-id",
+                "DEADBEEF",
+                "briefcase-macOS-DEADBEEF",
+            ],
+            check=True,
+            stream_output=False,
+        ),
+        # Staple credentials
+        mock.call(
+            [
+                "xcrun",
+                "stapler",
+                "staple",
+                tmp_path / "base_path/dist/First App-0.0.1.dmg",
+            ],
+            check=True,
+        ),
+    ]
 
 
 def test_credential_storage_failure_app(
     package_command,
-    first_app_with_binaries,
+    first_app_zip,
     sekrit_identity,
     tmp_path,
 ):
@@ -324,15 +521,22 @@ def test_credential_storage_failure_app(
         / "app"
         / "First App.app"
     )
-    archive_path = tmp_path / "base_path/build/first-app/macos/app/archive.zip"
+    archive_path = tmp_path / "base_path/build/first-app/macos/app/First App.app.zip"
+
+    # Mock the creation of the ditto archive
+    package_command.ditto_archive = MagicMock()
 
     # Set up subprocess to fail on the first notarization attempt,
     # then fail on the storage of credentials
-    package_command.tools.subprocess.run.side_effect = [
+    package_command.tools.subprocess.parse_output.side_effect = [
+        # notarytool submit failure
         subprocess.CalledProcessError(
             returncode=69,
             cmd=["xcrun", "notarytool", "submit"],
         ),  # Unknown credential failure
+    ]
+
+    package_command.tools.subprocess.run.side_effect = [
         subprocess.CalledProcessError(
             returncode=1,
             cmd=["xcrun", "notarytool", "store-credentials"],
@@ -344,44 +548,42 @@ def test_credential_storage_failure_app(
         BriefcaseCommandError,
         match=r"Unable to store credentials for team ID DEADBEEF.",
     ):
-        package_command.notarize(app_path, identity=sekrit_identity)
+        package_command.notarize(first_app_zip, identity=sekrit_identity)
 
-    # As a result of mocking os.unlink, the zip archive won't be
-    # cleaned up, so we can test for its existence, but also
+    # As a result of mocking ditto, the zip archive won't *actually* be created;
+    # and as a result of mocking os, it won't *actually* be deleted either - but we can
     # verify that it *would* have been deleted.
-    assert archive_path.exists()
+    package_command.ditto_archive.assert_called_once_with(app_path, archive_path)
     package_command.tools.os.unlink.assert_called_with(archive_path)
 
     # The calls to notarize were made
-    package_command.tools.subprocess.run.assert_has_calls(
+    # Submit *archive* for notarization
+    package_command.tools.subprocess.parse_output.assert_called_once_with(
+        json_parser,
         [
-            # Submit for notarization
-            mock.call(
-                [
-                    "xcrun",
-                    "notarytool",
-                    "submit",
-                    archive_path,
-                    "--keychain-profile",
-                    "briefcase-macOS-DEADBEEF",
-                    "--wait",
-                ],
-                check=True,
-            ),
-            # Store credentials in the keychain
-            mock.call(
-                [
-                    "xcrun",
-                    "notarytool",
-                    "store-credentials",
-                    "--team-id",
-                    "DEADBEEF",
-                    "briefcase-macOS-DEADBEEF",
-                ],
-                check=True,
-                stream_output=False,
-            ),
-        ]
+            "xcrun",
+            "notarytool",
+            "submit",
+            archive_path,
+            "--keychain-profile",
+            "briefcase-macOS-DEADBEEF",
+            "--output-format",
+            "json",
+        ],
+    )
+
+    # Store credentials in the keychain
+    package_command.tools.subprocess.run.assert_called_once_with(
+        [
+            "xcrun",
+            "notarytool",
+            "store-credentials",
+            "--team-id",
+            "DEADBEEF",
+            "briefcase-macOS-DEADBEEF",
+        ],
+        check=True,
+        stream_output=False,
     )
 
 
@@ -389,15 +591,23 @@ def test_credential_storage_failure_dmg(
     package_command,
     first_app_dmg,
     sekrit_identity,
+    tmp_path,
 ):
     """If credentials haven't been stored, and storage fails, an error is raised."""
+    # Mock the creation of the ditto archive
+    package_command.ditto_archive = MagicMock()
+
     # Set up subprocess to fail on the first notarization attempt,
     # then fail on the storage of credentials
-    package_command.tools.subprocess.run.side_effect = [
+    package_command.tools.subprocess.parse_output.side_effect = [
+        # notarytool submit failure
         subprocess.CalledProcessError(
             returncode=69,
             cmd=["xcrun", "notarytool", "submit"],
         ),  # Unknown credential failure
+    ]
+
+    package_command.tools.subprocess.run.side_effect = [
         subprocess.CalledProcessError(
             returncode=1,
             cmd=["xcrun", "notarytool", "store-credentials"],
@@ -411,45 +621,44 @@ def test_credential_storage_failure_dmg(
     ):
         package_command.notarize(first_app_dmg, identity=sekrit_identity)
 
-    # The DMG didn't require an archive file, so unlink wasn't invoked.
+    # The DMG didn't require an archive file, so ditto and unlink weren't invoked.
+    package_command.ditto_archive.assert_not_called()
     package_command.tools.os.unlink.assert_not_called()
 
     # The calls to notarize were made
-    package_command.tools.subprocess.run.assert_has_calls(
+    # Submit *archive* for notarization
+    package_command.tools.subprocess.parse_output.assert_called_once_with(
+        json_parser,
         [
-            # Submit for notarization
-            mock.call(
-                [
-                    "xcrun",
-                    "notarytool",
-                    "submit",
-                    first_app_dmg,
-                    "--keychain-profile",
-                    "briefcase-macOS-DEADBEEF",
-                    "--wait",
-                ],
-                check=True,
-            ),
-            # Store credentials in the keychain
-            mock.call(
-                [
-                    "xcrun",
-                    "notarytool",
-                    "store-credentials",
-                    "--team-id",
-                    "DEADBEEF",
-                    "briefcase-macOS-DEADBEEF",
-                ],
-                check=True,
-                stream_output=False,
-            ),
-        ]
+            "xcrun",
+            "notarytool",
+            "submit",
+            tmp_path / "base_path/dist/First App-0.0.1.dmg",
+            "--keychain-profile",
+            "briefcase-macOS-DEADBEEF",
+            "--output-format",
+            "json",
+        ],
+    )
+
+    # Store credentials in the keychain
+    package_command.tools.subprocess.run.assert_called_once_with(
+        [
+            "xcrun",
+            "notarytool",
+            "store-credentials",
+            "--team-id",
+            "DEADBEEF",
+            "briefcase-macOS-DEADBEEF",
+        ],
+        check=True,
+        stream_output=False,
     )
 
 
 def test_credential_storage_disabled_input_app(
     package_command,
-    first_app_with_binaries,
+    first_app_zip,
     sekrit_identity,
     tmp_path,
 ):
@@ -464,15 +673,28 @@ def test_credential_storage_disabled_input_app(
         / "app"
         / "First App.app"
     )
-    archive_path = tmp_path / "base_path/build/first-app/macos/app/archive.zip"
+    archive_path = tmp_path / "base_path/build/first-app/macos/app/First App.app.zip"
 
-    # Set up subprocess to fail on the first notarization attempt.
-    package_command.tools.subprocess.run.side_effect = [
+    # Mock the creation of the ditto archive
+    package_command.ditto_archive = MagicMock()
+
+    # Set up subprocess to fail on the first notarization attempt,
+    # then fail on the storage of credentials
+    package_command.tools.subprocess.parse_output.side_effect = [
+        # notarytool submit failure
         subprocess.CalledProcessError(
             returncode=69,
             cmd=["xcrun", "notarytool", "submit"],
         ),  # Unknown credential failure
     ]
+
+    package_command.tools.subprocess.run.side_effect = [
+        subprocess.CalledProcessError(
+            returncode=1,
+            cmd=["xcrun", "notarytool", "store-credentials"],
+        ),  # Credential verification failed
+    ]
+
     # Disable console input
     package_command.tools.input.enabled = False
 
@@ -481,48 +703,62 @@ def test_credential_storage_disabled_input_app(
         BriefcaseCommandError,
         match=r"The keychain does not contain credentials for the profile briefcase-macOS-DEADBEEF.",
     ):
-        package_command.notarize(app_path, identity=sekrit_identity)
+        package_command.notarize(first_app_zip, identity=sekrit_identity)
 
-    # As a result of mocking os.unlink, the zip archive won't be
-    # cleaned up, so we can test for its existence, but also
+    # As a result of mocking ditto, the zip archive won't *actually* be created;
+    # and as a result of mocking os, it won't *actually* be deleted either - but we can
     # verify that it *would* have been deleted.
-    assert archive_path.exists()
+    package_command.ditto_archive.assert_called_once_with(app_path, archive_path)
     package_command.tools.os.unlink.assert_called_with(archive_path)
 
     # The calls to notarize were made
-    package_command.tools.subprocess.run.assert_has_calls(
+    # Submit *archive* for notarization
+    package_command.tools.subprocess.parse_output.assert_called_once_with(
+        json_parser,
         [
-            # Submit for notarization
-            mock.call(
-                [
-                    "xcrun",
-                    "notarytool",
-                    "submit",
-                    archive_path,
-                    "--keychain-profile",
-                    "briefcase-macOS-DEADBEEF",
-                    "--wait",
-                ],
-                check=True,
-            ),
-        ]
+            "xcrun",
+            "notarytool",
+            "submit",
+            archive_path,
+            "--keychain-profile",
+            "briefcase-macOS-DEADBEEF",
+            "--output-format",
+            "json",
+        ],
     )
 
+    # No call is made to store credentials on the keychain
+    package_command.tools.subprocess.run.assert_not_called()
+
 
 def test_credential_storage_disabled_input_dmg(
     package_command,
     first_app_dmg,
     sekrit_identity,
+    tmp_path,
 ):
     """When packaging a DMG, if credentials haven't been stored, and input is disabled,
     an error is raised."""
-    # Set up subprocess to fail on the first notarization attempt.
-    package_command.tools.subprocess.run.side_effect = [
+    # Mock the creation of the ditto archive
+    package_command.ditto_archive = MagicMock()
+
+    # Set up subprocess to fail on the first notarization attempt,
+    # then fail on the storage of credentials
+    package_command.tools.subprocess.parse_output.side_effect = [
+        # notarytool submit failure
         subprocess.CalledProcessError(
             returncode=69,
             cmd=["xcrun", "notarytool", "submit"],
         ),  # Unknown credential failure
     ]
+
+    package_command.tools.subprocess.run.side_effect = [
+        subprocess.CalledProcessError(
+            returncode=1,
+            cmd=["xcrun", "notarytool", "store-credentials"],
+        ),  # Credential verification failed
+    ]
+
     # Disable console input
     package_command.tools.input.enabled = False
 
@@ -533,108 +769,114 @@ def test_credential_storage_disabled_input_dmg(
     ):
         package_command.notarize(first_app_dmg, identity=sekrit_identity)
 
-    # The DMG didn't require an archive file, so unlink wasn't invoked.
+    # The DMG didn't require an archive file, so ditto and unlink weren't invoked.
+    package_command.ditto_archive.assert_not_called()
     package_command.tools.os.unlink.assert_not_called()
 
     # The calls to notarize were made
-    package_command.tools.subprocess.run.assert_has_calls(
+    # Submit *archive* for notarization
+    package_command.tools.subprocess.parse_output.assert_called_once_with(
+        json_parser,
         [
-            # Submit for notarization
-            mock.call(
-                [
-                    "xcrun",
-                    "notarytool",
-                    "submit",
-                    first_app_dmg,
-                    "--keychain-profile",
-                    "briefcase-macOS-DEADBEEF",
-                    "--wait",
-                ],
-                check=True,
-            ),
-        ]
+            "xcrun",
+            "notarytool",
+            "submit",
+            tmp_path / "base_path/dist/First App-0.0.1.dmg",
+            "--keychain-profile",
+            "briefcase-macOS-DEADBEEF",
+            "--output-format",
+            "json",
+        ],
     )
 
+    # No attempt was made to store credentials in the keychain
+    package_command.tools.subprocess.run.assert_not_called()
+
 
 def test_notarize_unknown_credentials_after_storage(
     package_command,
     first_app_dmg,
     sekrit_identity,
+    sleep_zero,
+    tmp_path,
 ):
     """If we get a credential failure after an attempt to store, an error is raised."""
-    # Set up subprocess to fail on the second notarization attempt
-    package_command.tools.subprocess.run.side_effect = [
+    # Mock the creation of the ditto archive
+    package_command.ditto_archive = MagicMock()
+
+    # Set up subprocess to succeed on storing credentials, but fail on the second
+    # notarization attempt
+    package_command.tools.subprocess.parse_output.side_effect = [
         subprocess.CalledProcessError(
             returncode=69,
             cmd=["xcrun", "notarytool", "submit"],
         ),  # Unknown credential failure
-        None,  # Successful credential storage
         subprocess.CalledProcessError(
             returncode=69,
             cmd=["xcrun", "notarytool", "submit"],
-        ),  # A second unknown credential failure
-        None,  # Successful stapling
+        ),  # Unknown credential failure
     ]
 
     # The notarization call will fail with an error
     with pytest.raises(
         BriefcaseCommandError,
-        match=r"Unable to submit dist[/\\]First App.dmg for notarization.",
+        match=r"Unable to submit dist[/\\]First App-0.0.1.dmg for notarization.",
     ):
         package_command.notarize(first_app_dmg, identity=sekrit_identity)
 
-    # The DMG didn't require an archive file, so unlink wasn't invoked.
+    # The DMG didn't require an archive file, so ditto and unlink weren't invoked.
+    package_command.ditto_archive.assert_not_called()
     package_command.tools.os.unlink.assert_not_called()
 
     # The calls to notarize were made
-    package_command.tools.subprocess.run.assert_has_calls(
+    # Submit for notarization twice
+    assert package_command.tools.subprocess.parse_output.mock_calls == [
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "submit",
+                tmp_path / "base_path/dist/First App-0.0.1.dmg",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                "--output-format",
+                "json",
+            ],
+        ),
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "submit",
+                tmp_path / "base_path/dist/First App-0.0.1.dmg",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                "--output-format",
+                "json",
+            ],
+        ),
+    ]
+
+    # Store credentials in the keychain
+    package_command.tools.subprocess.run.assert_called_once_with(
         [
-            # Submit for notarization
-            mock.call(
-                [
-                    "xcrun",
-                    "notarytool",
-                    "submit",
-                    first_app_dmg,
-                    "--keychain-profile",
-                    "briefcase-macOS-DEADBEEF",
-                    "--wait",
-                ],
-                check=True,
-            ),
-            # Store credentials in the keychain
-            mock.call(
-                [
-                    "xcrun",
-                    "notarytool",
-                    "store-credentials",
-                    "--team-id",
-                    "DEADBEEF",
-                    "briefcase-macOS-DEADBEEF",
-                ],
-                check=True,
-                stream_output=False,
-            ),
-            # Submit for notarization a second time
-            mock.call(
-                [
-                    "xcrun",
-                    "notarytool",
-                    "submit",
-                    first_app_dmg,
-                    "--keychain-profile",
-                    "briefcase-macOS-DEADBEEF",
-                    "--wait",
-                ],
-                check=True,
-            ),
-        ]
+            "xcrun",
+            "notarytool",
+            "store-credentials",
+            "--team-id",
+            "DEADBEEF",
+            "briefcase-macOS-DEADBEEF",
+        ],
+        check=True,
+        stream_output=False,
     )
 
 
-def test_app_notarization_failure_with_credentials(
+def test_app_submit_notarization_failure_with_credentials(
     package_command,
-    first_app_with_binaries,
+    first_app_zip,
     sekrit_identity,
     tmp_path,
 ):
@@ -649,11 +891,14 @@ def test_app_notarization_failure_with_credentials(
         / "app"
         / "First App.app"
     )
-    archive_path = tmp_path / "base_path/build/first-app/macos/app/archive.zip"
+    archive_path = tmp_path / "base_path/build/first-app/macos/app/First App.app.zip"
+
+    # Mock the creation of the ditto archive
+    package_command.ditto_archive = MagicMock()
 
     # Set up subprocess to fail on the first notarization attempt
     # for a reason other than credentials
-    package_command.tools.subprocess.run.side_effect = [
+    package_command.tools.subprocess.parse_output.side_effect = [
         subprocess.CalledProcessError(
             returncode=42,
             cmd=["xcrun", "notarytool", "submit"],
@@ -665,44 +910,47 @@ def test_app_notarization_failure_with_credentials(
         BriefcaseCommandError,
         match=r"Unable to submit build[/\\]first-app[/\\]macos[/\\]app[/\\]First App.app for notarization.",
     ):
-        package_command.notarize(app_path, identity=sekrit_identity)
+        package_command.notarize(first_app_zip, identity=sekrit_identity)
 
-    # As a result of mocking os.unlink, the zip archive won't be
-    # cleaned up, so we can test for its existence, but also
+    # As a result of mocking ditto, the zip archive won't *actually* be created;
+    # and as a result of mocking os, it won't *actually* be deleted either - but we can
     # verify that it *would* have been deleted.
-    assert archive_path.exists()
+    package_command.ditto_archive.assert_called_once_with(app_path, archive_path)
     package_command.tools.os.unlink.assert_called_with(archive_path)
 
     # The calls to notarize were made
-    package_command.tools.subprocess.run.assert_has_calls(
+    package_command.tools.subprocess.parse_output.assert_called_once_with(
+        json_parser,
         [
-            # Submit for notarization
-            mock.call(
-                [
-                    "xcrun",
-                    "notarytool",
-                    "submit",
-                    archive_path,
-                    "--keychain-profile",
-                    "briefcase-macOS-DEADBEEF",
-                    "--wait",
-                ],
-                check=True,
-            ),
-        ]
+            "xcrun",
+            "notarytool",
+            "submit",
+            archive_path,
+            "--keychain-profile",
+            "briefcase-macOS-DEADBEEF",
+            "--output-format",
+            "json",
+        ],
     )
 
+    # No calls to submit credentials
+    package_command.tools.subprocess.run.assert_not_called()
+
 
-def test_dmg_notarization_failure_with_credentials(
+def test_dmg_submit_notarization_failure_with_credentials(
     package_command,
     first_app_dmg,
     sekrit_identity,
+    tmp_path,
 ):
     """If the notarization process for a DMG fails for a reason other than credentials,
     an error is raised."""
+    # Mock the creation of the ditto archive
+    package_command.ditto_archive = MagicMock()
+
     # Set up subprocess to fail on the first notarization attempt
     # for a reason other than credentials
-    package_command.tools.subprocess.run.side_effect = [
+    package_command.tools.subprocess.parse_output.side_effect = [
         subprocess.CalledProcessError(
             returncode=42,
             cmd=["xcrun", "notarytool", "submit"],
@@ -712,42 +960,130 @@ def test_dmg_notarization_failure_with_credentials(
     # The notarization call will fail with an error
     with pytest.raises(
         BriefcaseCommandError,
-        match=r"Unable to submit dist[/\\]First App.dmg for notarization.",
+        match=r"Unable to submit dist[/\\]First App-0.0.1.dmg for notarization.",
     ):
         package_command.notarize(first_app_dmg, identity=sekrit_identity)
 
-    # The DMG didn't require an archive file, so unlink wasn't invoked.
+    # The DMG didn't require an archive file, so ditto and unlink weren't invoked.
+    package_command.ditto_archive.assert_not_called()
     package_command.tools.os.unlink.assert_not_called()
 
     # The calls to notarize were made
-    package_command.tools.subprocess.run.assert_has_calls(
+    package_command.tools.subprocess.parse_output.assert_called_once_with(
+        json_parser,
         [
-            # Submit for notarization
-            mock.call(
-                [
-                    "xcrun",
-                    "notarytool",
-                    "submit",
-                    first_app_dmg,
-                    "--keychain-profile",
-                    "briefcase-macOS-DEADBEEF",
-                    "--wait",
-                ],
-                check=True,
-            ),
-        ]
+            "xcrun",
+            "notarytool",
+            "submit",
+            tmp_path / "base_path/dist/First App-0.0.1.dmg",
+            "--keychain-profile",
+            "briefcase-macOS-DEADBEEF",
+            "--output-format",
+            "json",
+        ],
     )
 
+    # No calls to submit credentials
+    package_command.tools.subprocess.run.assert_not_called()
+
+
+def test_unknown_notarization_status_failure(
+    package_command,
+    first_app_dmg,
+    sekrit_identity,
+    sleep_zero,
+    tmp_path,
+):
+    """If the notarization log process fails with an unexpected status code, an error is raised."""
+    # Mock the creation of the ditto archive
+    package_command.ditto_archive = MagicMock()
+
+    # Mock the return values of subprocesses
+    submission_id = str(uuid.uuid4())
+    package_command.tools.subprocess.parse_output.side_effect = [
+        # notarytool submit
+        {"id": submission_id},
+        # notarytool log; a failure with an unknown status code.
+        subprocess.CalledProcessError(
+            returncode=42,
+            cmd=["xcrun", "notarytool", "log"],
+        ),
+    ]
+
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=r"Unknown problem retrieving notarization status.",
+    ):
+        package_command.notarize(first_app_dmg, identity=sekrit_identity)
+
+    # The DMG didn't require an archive file, so ditto and unlink weren't invoked.
+    package_command.ditto_archive.assert_not_called()
+    package_command.tools.os.unlink.assert_not_called()
+
+    # The calls to notarization tools were made
+    assert package_command.tools.subprocess.parse_output.mock_calls == [
+        # Submit dmg for notarization
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "submit",
+                tmp_path / "base_path/dist/First App-0.0.1.dmg",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                "--output-format",
+                "json",
+            ],
+        ),
+        # One attempt to check status is made
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "log",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                submission_id,
+            ],
+        ),
+    ]
+
+    # No staple attempt is made.
+    package_command.tools.subprocess.run.assert_not_called()
+
 
 def test_stapling_failure(
     package_command,
     first_app_dmg,
     sekrit_identity,
+    sleep_zero,
+    tmp_path,
 ):
     """If the stapling process fails, an error is raised."""
+    # Mock the creation of the ditto archive
+    package_command.ditto_archive = MagicMock()
+
+    # Mock the return values of subprocesses
+    submission_id = str(uuid.uuid4())
+    package_command.tools.subprocess.parse_output.side_effect = [
+        # notarytool submit
+        {"id": submission_id},
+        # notarytool log; 2 failures, then a successful result.
+        subprocess.CalledProcessError(
+            returncode=69,
+            cmd=["xcrun", "notarytool", "log"],
+        ),
+        subprocess.CalledProcessError(
+            returncode=69,
+            cmd=["xcrun", "notarytool", "log"],
+        ),
+        {"status": "Accepted"},
+    ]
+
     # Set up a failure in the stapling process
     package_command.tools.subprocess.run.side_effect = [
-        None,
         subprocess.CalledProcessError(
             returncode=42,
             cmd=["xcrun", "stapler"],
@@ -756,38 +1092,162 @@ def test_stapling_failure(
 
     with pytest.raises(
         BriefcaseCommandError,
-        match=r"Unable to staple notarization onto dist[/\\]First App.dmg",
+        match=r"Unable to staple notarization onto dist[/\\]First App-0.0.1.dmg",
     ):
         package_command.notarize(first_app_dmg, identity=sekrit_identity)
 
-    # The DMG didn't require an archive file, so unlink wasn't invoked.
+    # The DMG didn't require an archive file, so ditto and unlink weren't invoked.
+    package_command.ditto_archive.assert_not_called()
     package_command.tools.os.unlink.assert_not_called()
 
-    # The calls to notarize were made
-    package_command.tools.subprocess.run.assert_has_calls(
+    # The calls to notarization tools were made
+    assert package_command.tools.subprocess.parse_output.mock_calls == [
+        # Submit dmg for notarization
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "submit",
+                tmp_path / "base_path/dist/First App-0.0.1.dmg",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                "--output-format",
+                "json",
+            ],
+        ),
+        # Check status 3 times
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "log",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                submission_id,
+            ],
+        ),
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "log",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                submission_id,
+            ],
+        ),
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "log",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                submission_id,
+            ],
+        ),
+    ]
+
+    package_command.tools.subprocess.run.assert_called_once_with(
         [
-            # Submit for notarization
-            mock.call(
-                [
-                    "xcrun",
-                    "notarytool",
-                    "submit",
-                    first_app_dmg,
-                    "--keychain-profile",
-                    "briefcase-macOS-DEADBEEF",
-                    "--wait",
-                ],
-                check=True,
-            ),
-            # Staple the result
-            mock.call(
-                [
-                    "xcrun",
-                    "stapler",
-                    "staple",
-                    first_app_dmg,
-                ],
-                check=True,
-            ),
-        ]
+            "xcrun",
+            "stapler",
+            "staple",
+            tmp_path / "base_path/dist/First App-0.0.1.dmg",
+        ],
+        check=True,
+    )
+
+
+def test_interrupt_notarization(
+    package_command,
+    first_app_dmg,
+    sekrit_identity,
+    sleep_zero,
+    tmp_path,
+    capsys,
+):
+    """If notarization is interrupted, the submission ID is output for the user."""
+    # Mock the return values of subprocesses
+    submission_id = str(uuid.uuid4())
+    package_command.tools.subprocess.parse_output.side_effect = [
+        # notarytool submit
+        {"id": submission_id},
+        # notarytool log; 2 failures, then a keyboard interrupt
+        subprocess.CalledProcessError(
+            returncode=69,
+            cmd=["xcrun", "notarytool", "log"],
+        ),
+        subprocess.CalledProcessError(
+            returncode=69,
+            cmd=["xcrun", "notarytool", "log"],
+        ),
+        KeyboardInterrupt,
+    ]
+
+    with pytest.raises(NotarizationInterrupted):
+        package_command.notarize(first_app_dmg, identity=sekrit_identity)
+
+    assert (
+        f"briefcase package macOS app -p dmg --identity CAFEBEEF --resume {submission_id}"
+        in capsys.readouterr().out
     )
+
+    # The calls to notarization tools were made
+    assert package_command.tools.subprocess.parse_output.mock_calls == [
+        # Submit dmg for notarization
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "submit",
+                tmp_path / "base_path/dist/First App-0.0.1.dmg",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                "--output-format",
+                "json",
+            ],
+        ),
+        # Check status 3 times
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "log",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                submission_id,
+            ],
+        ),
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "log",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                submission_id,
+            ],
+        ),
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "log",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                submission_id,
+            ],
+        ),
+    ]
+
+    # No stapling occurred
+    package_command.tools.subprocess.run.assert_not_called()
diff --git a/tests/platforms/macOS/app/package/test_package.py b/tests/platforms/macOS/app/package/test_package.py
index 359f6f265..bd0f8d544 100644
--- a/tests/platforms/macOS/app/package/test_package.py
+++ b/tests/platforms/macOS/app/package/test_package.py
@@ -70,6 +70,7 @@ def test_no_notarize_option(package_command):
         "installer_identity": None,
         "sign_installer": True,
         "packaging_format": None,
+        "submission_id": None,
         "update": False,
     }
     assert overrides == {}
@@ -88,6 +89,7 @@ def test_installer_identity_option(package_command):
         "installer_identity": "DEADBEEF",
         "sign_installer": True,
         "packaging_format": None,
+        "submission_id": None,
         "update": False,
     }
     assert overrides == {}
@@ -104,6 +106,24 @@ def test_no_sign_installer(package_command):
         "installer_identity": None,
         "sign_installer": False,
         "packaging_format": None,
+        "submission_id": None,
+        "update": False,
+    }
+    assert overrides == {}
+
+
+def test_resume(package_command):
+    """The --resume option can be parsed."""
+    options, overrides = package_command.parse_options(["--resume", "cafe-beef-1234"])
+
+    assert options == {
+        "adhoc_sign": False,
+        "identity": None,
+        "notarize_app": None,
+        "installer_identity": None,
+        "sign_installer": True,
+        "packaging_format": None,
+        "submission_id": "cafe-beef-1234",
         "update": False,
     }
     assert overrides == {}
@@ -149,8 +169,13 @@ def test_package_app(
     # Select a codesigning identity
     package_command.select_identity.return_value = sekrit_identity
 
-    # Package the app. Sign and notarize by default
-    package_command.package_app(first_app_with_binaries)
+    # Package the app. Sign and notarize by default. Use the base command's interface to
+    # ensure the full cleanup process is tested.
+    package_command._package_app(
+        first_app_with_binaries,
+        update=False,
+        packaging_format="dmg",
+    )
 
     # A request has been made to sign the app
     package_command.sign_app.assert_called_once_with(
@@ -195,7 +220,7 @@ def test_package_app(
 
     # A request was made to notarize the DMG
     package_command.notarize.assert_called_once_with(
-        tmp_path / "base_path/dist/First App-0.0.1.dmg",
+        first_app_with_binaries,
         identity=sekrit_identity,
     )
 
@@ -215,8 +240,14 @@ def test_no_notarization(
     # Select a codesigning identity
     package_command.select_identity.return_value = sekrit_identity
 
-    # Package the app; sign by default, but disable notarization
-    package_command.package_app(first_app_with_binaries, notarize_app=False)
+    # Package the app; sign by default, but disable notarization. Use the base command's
+    # interface to ensure the full cleanup process is tested.
+    package_command._package_app(
+        first_app_with_binaries,
+        notarize_app=False,
+        update=False,
+        packaging_format="dmg",
+    )
 
     # A request has been made to sign the app
     package_command.sign_app.assert_called_once_with(
@@ -274,10 +305,13 @@ def test_adhoc_sign(
     tmp_path,
 ):
     """A macOS App can be packaged and signed with ad-hoc identity."""
-    # Package the app with an ad-hoc identity.
-    # Explicitly disable notarization (can't ad-hoc notarize an app)
-    package_command.package_app(
+    # Package the app with an ad-hoc identity. Explicitly disable notarization (can't
+    # ad-hoc notarize an app). Use the base command's interface to ensure the full
+    # cleanup process is tested.
+    package_command._package_app(
         first_app_with_binaries,
+        update=False,
+        packaging_format="dmg",
         adhoc_sign=True,
         notarize_app=False,
     )
@@ -330,13 +364,16 @@ def test_adhoc_sign(
 def test_notarize_adhoc_signed(package_command, first_app_with_binaries):
     """A macOS App cannot be notarized if ad-hoc signing is requested."""
 
-    # Package the app without code signing
+    # Package the app without code signing. Use the base command's interface to ensure
+    # the full cleanup process is tested.
     with pytest.raises(
         BriefcaseCommandError,
         match=r"Can't notarize an app with an ad-hoc signing identity",
     ):
-        package_command.package_app(
+        package_command._package_app(
             first_app_with_binaries,
+            update=False,
+            packaging_format="dmg",
             notarize_app=True,
             adhoc_sign=True,
         )
@@ -357,13 +394,16 @@ def test_notarize_adhoc_signed_via_prompt(
 
     package_command.select_identity.return_value = adhoc_identity
 
-    # Package the app without code signing
+    # Package the app without code signing. Use the base command's interface to ensure the full
+    # cleanup process is tested.
     with pytest.raises(
         BriefcaseCommandError,
         match=r"Can't notarize an app with an ad-hoc signing identity",
     ):
-        package_command.package_app(
+        package_command._package_app(
             first_app_with_binaries,
+            update=False,
+            packaging_format="dmg",
             notarize_app=True,
         )
 
@@ -381,10 +421,13 @@ def test_adhoc_sign_default_no_notarization(
     tmp_path,
 ):
     """An ad-hoc signed app is not notarized by default."""
-    # Package the app with an ad-hoc identity; notarization will
-    # be disabled as a default
-    package_command.package_app(
+    # Package the app with an ad-hoc identity; notarization will be disabled as a
+    # default. Use the base command's interface to ensure the full cleanup process is
+    # tested.
+    package_command._package_app(
         first_app_with_binaries,
+        update=False,
+        packaging_format="dmg",
         adhoc_sign=True,
     )
 
@@ -447,9 +490,14 @@ def test_sign_failure(
     # Raise an error when attempting to sign the app
     package_command.sign_app.side_effect = BriefcaseCommandError("Unable to code sign")
 
-    # Attempt to package the app; it should raise an error
+    # Attempt to package the app; it should raise an error. Use the base command's
+    # interface to ensure the full cleanup process is tested.
     with pytest.raises(BriefcaseCommandError, match=r"Unable to code sign"):
-        package_command.package_app(first_app_with_binaries)
+        package_command._package_app(
+            first_app_with_binaries,
+            update=False,
+            packaging_format="dmg",
+        )
 
     # A request has been made to sign the app
     package_command.sign_app.assert_called_once_with(
diff --git a/tests/platforms/macOS/app/package/test_package_pkg.py b/tests/platforms/macOS/app/package/test_package_pkg.py
index e5a1d6dfe..caebb3ea3 100644
--- a/tests/platforms/macOS/app/package/test_package_pkg.py
+++ b/tests/platforms/macOS/app/package/test_package_pkg.py
@@ -73,6 +73,12 @@ def test_gui_app(
     # App content has been copied into place.
     assert (bundle_path / "installer/root/First App.app/Contents/Info.plist").is_file()
 
+    # When duplicating the app, symlinks have been preserved
+    assert (
+        bundle_path
+        / "installer/root/First App.app/Contents/Frameworks/Extras.framework/Extras"
+    ).is_symlink()
+
     # The license has been updated.
     assert (bundle_path / "installer/resources/LICENSE").read_text(
         encoding="utf-8"
@@ -125,8 +131,9 @@ def test_gui_app(
 
     # Notarization was performed with the installer identity
     package_command.notarize.assert_called_once_with(
-        tmp_path / "base_path/dist/First App-0.0.1.pkg",
-        identity=sekrit_installer_identity,
+        first_app_with_binaries,
+        identity=sekrit_identity,
+        installer_identity=sekrit_installer_identity,
     )
 
 
@@ -325,8 +332,9 @@ def test_console_app(
 
     # Notarization was performed with the installer identity
     package_command.notarize.assert_called_once_with(
-        tmp_path / "base_path/dist/First App-0.0.1.pkg",
-        identity=sekrit_installer_identity,
+        first_app_with_binaries,
+        identity=sekrit_identity,
+        installer_identity=sekrit_installer_identity,
     )
 
 
diff --git a/tests/platforms/macOS/app/package/test_package_zip.py b/tests/platforms/macOS/app/package/test_package_zip.py
index aa6f7b505..8331a6b02 100644
--- a/tests/platforms/macOS/app/package/test_package_zip.py
+++ b/tests/platforms/macOS/app/package/test_package_zip.py
@@ -1,13 +1,15 @@
-from zipfile import ZipFile
+from unittest.mock import MagicMock
 
 
 def test_package_zip(
     package_command,
     first_app_with_binaries,
     sekrit_identity,
-    tmp_path,
 ):
     """A macOS App can be packaged as a zip."""
+    # Mock the creation of the ditto archive
+    package_command.ditto_archive = MagicMock()
+
     # Select zip packaging
     first_app_with_binaries.packaging_format = "zip"
 
@@ -25,13 +27,7 @@ def test_package_zip(
 
     # A request has been made to notarize the app
     package_command.notarize.assert_called_once_with(
-        tmp_path
-        / "base_path"
-        / "build"
-        / "first-app"
-        / "macos"
-        / "app"
-        / "First App.app",
+        first_app_with_binaries,
         identity=sekrit_identity,
     )
 
@@ -43,43 +39,21 @@ def test_package_zip(
     # by calling sign_app()
     assert package_command.sign_file.call_count == 0
 
-    # The packaged archive exists, and contains all the files,
-    # contained in the `.app` bundle.
-    archive_file = tmp_path / "base_path/dist/First App-0.0.1.app.zip"
-    assert archive_file.exists()
-    with ZipFile(archive_file) as archive:
-        assert sorted(archive.namelist()) == [
-            "First App.app/",
-            "First App.app/Contents/",
-            "First App.app/Contents/Frameworks/",
-            "First App.app/Contents/Frameworks/Extras.framework/",
-            "First App.app/Contents/Frameworks/Extras.framework/Resources/",
-            "First App.app/Contents/Frameworks/Extras.framework/Resources/extras.dylib",
-            "First App.app/Contents/Info.plist",
-            "First App.app/Contents/MacOS/",
-            "First App.app/Contents/MacOS/First App",
-            "First App.app/Contents/Resources/",
-            "First App.app/Contents/Resources/app_packages/",
-            "First App.app/Contents/Resources/app_packages/Extras.app/",
-            "First App.app/Contents/Resources/app_packages/Extras.app/Contents/",
-            "First App.app/Contents/Resources/app_packages/Extras.app/Contents/MacOS/",
-            "First App.app/Contents/Resources/app_packages/Extras.app/Contents/MacOS/Extras",
-            "First App.app/Contents/Resources/app_packages/first.other",
-            "First App.app/Contents/Resources/app_packages/first_dylib.dylib",
-            "First App.app/Contents/Resources/app_packages/first_so.so",
-            "First App.app/Contents/Resources/app_packages/first_symlink.so",
-            "First App.app/Contents/Resources/app_packages/other_binary",
-            "First App.app/Contents/Resources/app_packages/second.other",
-            "First App.app/Contents/Resources/app_packages/special.binary",
-            "First App.app/Contents/Resources/app_packages/subfolder/",
-            "First App.app/Contents/Resources/app_packages/subfolder/second_dylib.dylib",
-            "First App.app/Contents/Resources/app_packages/subfolder/second_so.so",
-            "First App.app/Contents/Resources/app_packages/unknown.binary",
-        ]
-
-
-def test_zip_no_notarization(package_command, sekrit_identity, first_app_with_binaries):
+    # The notarization process will create the final artefact;
+    # since we're mocking notarize, we rely on the notarization tests
+    # to verify that the final distribution artefact was created.
+
+
+def test_zip_no_notarization(
+    package_command,
+    sekrit_identity,
+    first_app_with_binaries,
+    tmp_path,
+):
     """A macOS App can be packaged as a zip, without notarization."""
+    # Mock the creation of the ditto archive
+    package_command.ditto_archive = MagicMock()
+
     # Select zip packaging
     first_app_with_binaries.packaging_format = "zip"
 
@@ -108,3 +82,9 @@ def test_zip_no_notarization(package_command, sekrit_identity, first_app_with_bi
     # This ignores the calls that would have been made transitively
     # by calling sign_app()
     assert package_command.sign_file.call_count == 0
+
+    # Since we're not notarizing, the packaged archive was created.
+    package_command.ditto_archive.assert_called_once_with(
+        tmp_path / "base_path/build/first-app/macos/app/First App.app",
+        tmp_path / "base_path/dist/First App-0.0.1.app.zip",
+    )
diff --git a/tests/platforms/macOS/app/package/test_resume_notarization.py b/tests/platforms/macOS/app/package/test_resume_notarization.py
new file mode 100644
index 000000000..064d8ad85
--- /dev/null
+++ b/tests/platforms/macOS/app/package/test_resume_notarization.py
@@ -0,0 +1,1019 @@
+import shutil
+import subprocess
+import uuid
+from pathlib import Path
+from unittest import mock
+
+import pytest
+
+from briefcase.exceptions import BriefcaseCommandError
+from briefcase.integrations.subprocess import json_parser
+
+from .....utils import create_file
+
+
+def test_resume_notarize_app(
+    package_command,
+    first_app_with_binaries,
+    sekrit_identity,
+    tmp_path,
+    sleep_zero,
+):
+    """Notarization of an app bundle can be resumed."""
+    # Select a codesigning identity
+    package_command.select_identity.return_value = sekrit_identity
+
+    # Mock the creation of the ditto archive
+    package_command.ditto_archive = mock.MagicMock()
+
+    # Mock the return values of subprocesses
+    submission_id = str(uuid.uuid4())
+    package_command.tools.subprocess.parse_output.side_effect = [
+        # notarytool history returns list with the app's submission ID
+        {
+            "history": [
+                {
+                    "id": str(uuid.uuid4()),
+                    "name": "Other App-1.2.3.dmg",
+                    "status": "Accepted",
+                },
+                {
+                    "id": submission_id,
+                    "name": "First App.app.zip",
+                    "status": "In Progress",
+                },
+            ]
+        },
+        # notarytool log; 2 failures, then a successful result.
+        subprocess.CalledProcessError(
+            returncode=69,
+            cmd=["xcrun", "notarytool", "log"],
+        ),
+        subprocess.CalledProcessError(
+            returncode=69,
+            cmd=["xcrun", "notarytool", "log"],
+        ),
+        {"status": "Accepted"},
+    ]
+
+    # Resume notarizaton. Use the base command's interface to ensure the full cleanup
+    # process is tested.
+    package_command._package_app(
+        first_app_with_binaries,
+        update=False,
+        packaging_format="zip",
+        identity=sekrit_identity.id,
+        submission_id=submission_id,
+    )
+
+    # Identity selection excluded adhoc identities
+    package_command.select_identity.assert_called_once_with(
+        identity=sekrit_identity.id,
+        allow_adhoc=False,
+    )
+
+    # The calls to notarization tools were made
+    assert package_command.tools.subprocess.parse_output.mock_calls == [
+        # Retrieve notarization history to verify the submission ID
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "history",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                "--output-format",
+                "json",
+            ],
+        ),
+        # Check status 3 times
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "log",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                submission_id,
+            ],
+        ),
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "log",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                submission_id,
+            ],
+        ),
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "log",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                submission_id,
+            ],
+        ),
+    ]
+
+    # Notarization is complete; we can staple.
+    package_command.tools.subprocess.run.assert_called_once_with(
+        [
+            "xcrun",
+            "stapler",
+            "staple",
+            tmp_path / "base_path/build/first-app/macos/app/First App.app",
+        ],
+        check=True,
+    )
+
+    # As this is a .zip distribution, there's a finalization step to create the actual
+    # distribution artefact with ditto.
+    package_command.ditto_archive.assert_called_once_with(
+        tmp_path / "base_path/build/first-app/macos/app/First App.app",
+        tmp_path / "base_path/dist/First App-0.0.1.app.zip",
+    )
+
+
+def test_resume_notarize_dmg(
+    package_command,
+    first_app_with_binaries,
+    sekrit_identity,
+    tmp_path,
+    sleep_zero,
+):
+    """Notarization of a DMG can be resumed."""
+    # Create a pre-existing distribution artefact
+    create_file(
+        tmp_path / "base_path/dist/First App-0.0.1.dmg",
+        "distribution file",
+    )
+
+    # Select a codesigning identity
+    package_command.select_identity.return_value = sekrit_identity
+
+    # Mock the creation of the ditto archive
+    package_command.ditto_archive = mock.MagicMock()
+
+    # Mock the return values of subprocesses
+    submission_id = str(uuid.uuid4())
+    package_command.tools.subprocess.parse_output.side_effect = [
+        # notarytool history returns list with the app's submission ID
+        {
+            "history": [
+                {
+                    "id": str(uuid.uuid4()),
+                    "name": "Other App-1.2.3.dmg",
+                    "status": "Accepted",
+                },
+                {
+                    "id": submission_id,
+                    "name": "First App-0.0.1.dmg",
+                    "status": "In Progress",
+                },
+            ]
+        },
+        # notarytool log; 2 failures, then a successful result.
+        subprocess.CalledProcessError(
+            returncode=69,
+            cmd=["xcrun", "notarytool", "log"],
+        ),
+        subprocess.CalledProcessError(
+            returncode=69,
+            cmd=["xcrun", "notarytool", "log"],
+        ),
+        {"status": "Accepted"},
+    ]
+
+    # Resume notarizaton
+    package_command._package_app(
+        first_app_with_binaries,
+        update=False,
+        packaging_format="dmg",
+        identity=sekrit_identity.id,
+        submission_id=submission_id,
+    )
+
+    # Identity selection excluded adhoc identities
+    package_command.select_identity.assert_called_once_with(
+        identity=sekrit_identity.id,
+        allow_adhoc=False,
+    )
+
+    # The calls to notarization tools were made
+    assert package_command.tools.subprocess.parse_output.mock_calls == [
+        # Retrieve notarization history to verify the submission ID
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "history",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                "--output-format",
+                "json",
+            ],
+        ),
+        # Check status 3 times
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "log",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                submission_id,
+            ],
+        ),
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "log",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                submission_id,
+            ],
+        ),
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "log",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                submission_id,
+            ],
+        ),
+    ]
+
+    # Notarization is complete; we can staple.
+    package_command.tools.subprocess.run.assert_called_once_with(
+        [
+            "xcrun",
+            "stapler",
+            "staple",
+            tmp_path / "base_path/dist/First App-0.0.1.dmg",
+        ],
+        check=True,
+    )
+
+    # The distribution artefact already exists, so there's no call to ditto.
+    package_command.ditto_archive.assert_not_called()
+
+
+def test_resume_notarize_pkg(
+    package_command,
+    first_app_with_binaries,
+    sekrit_identity,
+    sekrit_installer_identity,
+    tmp_path,
+    sleep_zero,
+):
+    """Notarization of a PKG installer can be resumed."""
+    # Create a pre-existing distribution artefact
+    create_file(
+        tmp_path / "base_path/dist/First App-0.0.1.pkg",
+        "distribution file",
+    )
+
+    # 2 calls are made to determine identity - the app identity, then the installer identity.
+    package_command.select_identity.side_effect = [
+        sekrit_identity,
+        sekrit_installer_identity,
+    ]
+
+    # Mock the creation of the ditto archive
+    package_command.ditto_archive = mock.MagicMock()
+
+    # Mock the return values of subprocesses
+    submission_id = str(uuid.uuid4())
+    package_command.tools.subprocess.parse_output.side_effect = [
+        # notarytool history returns list with the app's submission ID
+        {
+            "history": [
+                {
+                    "id": str(uuid.uuid4()),
+                    "name": "Other App-1.2.3.dmg",
+                    "status": "Accepted",
+                },
+                {
+                    "id": submission_id,
+                    "name": "First App-0.0.1.pkg",
+                    "status": "In Progress",
+                },
+            ]
+        },
+        # notarytool log; 2 failures, then a successful result.
+        subprocess.CalledProcessError(
+            returncode=69,
+            cmd=["xcrun", "notarytool", "log"],
+        ),
+        subprocess.CalledProcessError(
+            returncode=69,
+            cmd=["xcrun", "notarytool", "log"],
+        ),
+        {"status": "Accepted"},
+    ]
+
+    # Resume notarizaton
+    package_command._package_app(
+        first_app_with_binaries,
+        update=False,
+        packaging_format="pkg",
+        identity=sekrit_identity.id,
+        installer_identity=sekrit_installer_identity.id,
+        submission_id=submission_id,
+    )
+
+    # Identity selection excluded adhoc identities, but also confirmed notarization identity
+    assert package_command.select_identity.mock_calls == [
+        mock.call(
+            identity=sekrit_identity.id,
+            allow_adhoc=False,
+        ),
+        mock.call(
+            identity=sekrit_installer_identity.id,
+            app_identity=sekrit_identity,
+        ),
+    ]
+
+    # The calls to notarization tools were made
+    assert package_command.tools.subprocess.parse_output.mock_calls == [
+        # Retrieve notarization history to verify the submission ID
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "history",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                "--output-format",
+                "json",
+            ],
+        ),
+        # Check status 3 times
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "log",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                submission_id,
+            ],
+        ),
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "log",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                submission_id,
+            ],
+        ),
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "log",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                submission_id,
+            ],
+        ),
+    ]
+
+    # Notarization is complete; we can staple.
+    package_command.tools.subprocess.run.assert_called_once_with(
+        [
+            "xcrun",
+            "stapler",
+            "staple",
+            tmp_path / "base_path/dist/First App-0.0.1.pkg",
+        ],
+        check=True,
+    )
+
+    # The distribution artefact already exists, so there's no call to ditto.
+    package_command.ditto_archive.assert_not_called()
+
+
+def test_resume_notarize_app_artefact_missing(
+    package_command,
+    first_app_with_binaries,
+    sekrit_identity,
+    tmp_path,
+):
+    """If the app binary doesn't exist, notarization of an app cannot be resumed."""
+    # Delete the actual binary
+    shutil.rmtree(tmp_path / "base_path/build/first-app/macos/app/First App.app")
+
+    # Mock the command factory to prevent the binary from being rebuilt as part of
+    # package dependencies.
+    package_command._command_factory = mock.MagicMock()
+
+    # Attempting to resume notarization when there's no pre-existing artefact raises an
+    # error.
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=(
+            r"Notarization cannot be resumed, as the notarization artefact "
+            r"associated with this app \(.*First App.app\) does not exist."
+        ),
+    ):
+        package_command._package_app(
+            first_app_with_binaries,
+            update=False,
+            packaging_format="zip",
+            identity=sekrit_identity.id,
+            submission_id=str(uuid.uuid4()),
+        )
+
+
+def test_resume_notarize_app_dist_artefact_exists(
+    package_command,
+    first_app_with_binaries,
+    sekrit_identity,
+    tmp_path,
+    sleep_zero,
+):
+    """A *distribution* artefact of an app bundle will be cleaned up as part of resuming notarization."""
+    # Create a pre-existing distribution artefact
+    create_file(
+        tmp_path / "base_path/dist/First App-0.0.1.app.zip",
+        "distribution file",
+    )
+
+    # Select a codesigning identity
+    package_command.select_identity.return_value = sekrit_identity
+
+    # Mock the creation of the ditto archive
+    package_command.ditto_archive = mock.MagicMock()
+
+    # Mock the return values of subprocesses
+    submission_id = str(uuid.uuid4())
+    package_command.tools.subprocess.parse_output.side_effect = [
+        # notarytool history returns list with the app's submission ID
+        {
+            "history": [
+                {
+                    "id": str(uuid.uuid4()),
+                    "name": "Other App-1.2.3.dmg",
+                    "status": "Accepted",
+                },
+                {
+                    "id": submission_id,
+                    "name": "First App.app.zip",
+                    "status": "In Progress",
+                },
+            ]
+        },
+        # notarytool log; 2 failures, then a successful result.
+        subprocess.CalledProcessError(
+            returncode=69,
+            cmd=["xcrun", "notarytool", "log"],
+        ),
+        subprocess.CalledProcessError(
+            returncode=69,
+            cmd=["xcrun", "notarytool", "log"],
+        ),
+        {"status": "Accepted"},
+    ]
+
+    # Resume notarizaton. Use the base command's interface to ensure the full cleanup
+    # process is tested.
+    package_command._package_app(
+        first_app_with_binaries,
+        update=False,
+        packaging_format="zip",
+        identity=sekrit_identity.id,
+        submission_id=submission_id,
+    )
+
+    # Identity selection excluded adhoc identities
+    package_command.select_identity.assert_called_once_with(
+        identity=sekrit_identity.id,
+        allow_adhoc=False,
+    )
+
+    # The calls to notarization tools were made
+    assert package_command.tools.subprocess.parse_output.mock_calls == [
+        # Retrieve notarization history to verify the submission ID
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "history",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                "--output-format",
+                "json",
+            ],
+        ),
+        # Check status 3 times
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "log",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                submission_id,
+            ],
+        ),
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "log",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                submission_id,
+            ],
+        ),
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "log",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                submission_id,
+            ],
+        ),
+    ]
+
+    # Notarization is complete; we can staple.
+    package_command.tools.subprocess.run.assert_called_once_with(
+        [
+            "xcrun",
+            "stapler",
+            "staple",
+            tmp_path / "base_path/build/first-app/macos/app/First App.app",
+        ],
+        check=True,
+    )
+
+    # As this is a .zip distribution, there's a finalization step to create the actual
+    # distribution artefact with ditto. This file won't actually be created though, as
+    # we're mocking ditto. However, as a result of resuming with a pre-existing
+    # distribution artefact, the pre-existing artefact will have been deleted.
+    package_command.ditto_archive.assert_called_once_with(
+        tmp_path / "base_path/build/first-app/macos/app/First App.app",
+        tmp_path / "base_path/dist/First App-0.0.1.app.zip",
+    )
+
+    assert not (tmp_path / "base_path/dist/First App-0.0.1.app.zip").exists()
+
+
+def test_resume_notarize_artefact_missing(
+    package_command,
+    first_app_with_binaries,
+    sekrit_identity,
+):
+    """If the distribution artefact doesn't exist, notarization cannot be resumed."""
+
+    # Attempting to resume notarization when there's no pre-existing artefact raises an
+    # error.
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=(
+            r"Notarization cannot be resumed, as the notarization artefact "
+            r"associated with this app \(.*First App-0.0.1.dmg\) does not exist."
+        ),
+    ):
+        package_command._package_app(
+            first_app_with_binaries,
+            update=False,
+            packaging_format="dmg",
+            identity=sekrit_identity.id,
+            submission_id=str(uuid.uuid4()),
+        )
+
+
+def test_invalid_submission_id(
+    package_command,
+    first_app_with_binaries,
+    sekrit_identity,
+    tmp_path,
+):
+    """If an invalid submission ID is provided, an error is raised."""
+    # Create a pre-existing distribution artefact
+    create_file(
+        tmp_path / "base_path/dist/First App-0.0.1.dmg",
+        "distribution file",
+    )
+
+    # Select a codesigning identity
+    package_command.select_identity.return_value = sekrit_identity
+
+    # Mock the return values of subprocesses
+    submission_id = str(uuid.uuid4())
+    package_command.tools.subprocess.parse_output.side_effect = [
+        # notarytool history returns list with submission IDs that don't
+        # match the expected submission ID
+        {
+            "history": [
+                {
+                    "id": str(uuid.uuid4()),
+                    "name": "Other App-1.2.3.dmg",
+                    "status": "Accepted",
+                },
+                {
+                    "id": str(uuid.uuid4()),
+                    "name": "First App-0.0.1.dmg",
+                    "status": "In Progress",
+                },
+            ]
+        },
+    ]
+
+    # Attempt to resume notarizaton. The submission ID will be rejected.
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=rf"{submission_id} is not a known submission ID for this identity.",
+    ):
+        package_command._package_app(
+            first_app_with_binaries,
+            update=False,
+            packaging_format="dmg",
+            identity=sekrit_identity.id,
+            submission_id=submission_id,
+        )
+
+    # The history has been retrieved.
+    package_command.tools.subprocess.parse_output.assert_called_once_with(
+        json_parser,
+        [
+            "xcrun",
+            "notarytool",
+            "history",
+            "--keychain-profile",
+            "briefcase-macOS-DEADBEEF",
+            "--output-format",
+            "json",
+        ],
+    )
+
+    # No staple attempt is made
+    package_command.tools.subprocess.run.assert_not_called()
+
+
+def test_invalid_notarization_identity(
+    package_command,
+    first_app_with_binaries,
+    sekrit_identity,
+    tmp_path,
+):
+    """If an invalid notarization identity is provided, an error is raised."""
+    # Create a pre-existing distribution artefact
+    create_file(
+        tmp_path / "base_path/dist/First App-0.0.1.dmg",
+        "distribution file",
+    )
+
+    # Select a codesigning identity
+    package_command.select_identity.return_value = sekrit_identity
+
+    # Mock the return values of subprocesses
+    submission_id = str(uuid.uuid4())
+    package_command.tools.subprocess.parse_output.side_effect = [
+        # notarytool history returns an error
+        subprocess.CalledProcessError(69, cmd="xcrun notarytool history")
+    ]
+
+    # Attempt to resume notarizaton. The submission ID will not be validated
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=(
+            r"Unable to invoke notarytool to determine validity of submission ID.\n"
+            r"Are you sure this is the identity that was used to notarize the app\?"
+        ),
+    ):
+        package_command._package_app(
+            first_app_with_binaries,
+            update=False,
+            packaging_format="dmg",
+            identity=sekrit_identity.id,
+            submission_id=submission_id,
+        )
+
+    # The history has been retrieved.
+    package_command.tools.subprocess.parse_output.assert_called_once_with(
+        json_parser,
+        [
+            "xcrun",
+            "notarytool",
+            "history",
+            "--keychain-profile",
+            "briefcase-macOS-DEADBEEF",
+            "--output-format",
+            "json",
+        ],
+    )
+
+    # No staple attempt is made
+    package_command.tools.subprocess.run.assert_not_called()
+
+
+@pytest.mark.parametrize(
+    "dist_filename",
+    [
+        "First App-0.0.1.app.zip",
+        "First App-0.0.1.dmg",
+        "First App-0.0.1.pkg",
+    ],
+)
+def test_filename_mismatch(
+    package_command,
+    first_app_with_binaries,
+    sekrit_identity,
+    tmp_path,
+    dist_filename,
+):
+    """If the filename on a notarization submission doesn't match the current artefact, an error is raised."""
+    # Create a pre-existing distribution artefact
+    create_file(
+        tmp_path / "base_path/dist" / dist_filename,
+        "distribution file",
+    )
+
+    # Select a codesigning identity
+    package_command.select_identity.return_value = sekrit_identity
+
+    # Mock the return values of subprocesses
+    submission_id = str(uuid.uuid4())
+    package_command.tools.subprocess.parse_output.side_effect = [
+        # notarytool history returns list with a submission ID that matches,
+        # but the filename won't.
+        {
+            "history": [
+                {
+                    "id": str(uuid.uuid4()),
+                    "name": "Other App-1.2.3.dmg",
+                    "status": "Accepted",
+                },
+                {
+                    "id": submission_id,
+                    "name": "Third App-0.0.1.dmg",
+                    "status": "In Progress",
+                },
+            ]
+        },
+    ]
+
+    # Attempt to resume notarizaton. The submission ID will be rejected.
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=(
+            rf"{submission_id} is not a submission ID for this project. "
+            r"It notarizes a file named Third App-0.0.1.dmg"
+        ),
+    ):
+        package_command._package_app(
+            first_app_with_binaries,
+            update=False,
+            packaging_format=Path(dist_filename).suffix[1:],
+            identity=sekrit_identity.id,
+            submission_id=submission_id,
+        )
+
+    # The history has been retrieved.
+    package_command.tools.subprocess.parse_output.assert_called_once_with(
+        json_parser,
+        [
+            "xcrun",
+            "notarytool",
+            "history",
+            "--keychain-profile",
+            "briefcase-macOS-DEADBEEF",
+            "--output-format",
+            "json",
+        ],
+    )
+
+    # No staple attempt is made
+    package_command.tools.subprocess.run.assert_not_called()
+
+
+@pytest.mark.parametrize(
+    "response, error",
+    [
+        # A response with an unknown status code.
+        (
+            {"status": "wibble"},
+            r"Unexpected notarization status: wibble",
+        ),
+        # A bare bones response. This isn't something we've seen in practice, but we
+        # protect against it anyway.
+        (
+            {"status": "Invalid"},
+            r"Notarization was rejected: No details provided",
+        ),
+        # A failure with no issues provided.
+        (
+            {"status": "Invalid", "statusSummary": "Bad mojo"},
+            r"Notarization was rejected: Bad mojo",
+        ),
+        # A failure with an empty list of issues.
+        (
+            {"status": "Invalid", "statusSummary": "Bad mojo", "issues": []},
+            r"Notarization was rejected: Bad mojo",
+        ),
+        # A failure with a list of minimalist issues
+        (
+            {
+                "status": "Invalid",
+                "statusSummary": "Bad mojo",
+                "issues": [
+                    {
+                        "path": "foo/bar",
+                        "message": "This isn't good",
+                    },
+                    {
+                        "path": "pork/ham",
+                        "message": "This is fairly bad",
+                    },
+                ],
+            },
+            (
+                r"Notarization was rejected: Bad mojo\n"
+                r"\n"
+                r"    \* \(\?\) foo/bar \[unknown architecture\]\n"
+                r"      This isn't good\n"
+                r"      \(No additional help available\)\n"
+                r"\n"
+                r"    \* \(\?\) pork/ham \[unknown architecture\]\n"
+                r"      This is fairly bad\n"
+                r"      \(No additional help available\)"
+            ),
+        ),
+        # A failure with a fully populated list of issues
+        (
+            {
+                "status": "Invalid",
+                "statusSummary": "Bad mojo",
+                "issues": [
+                    {
+                        "severity": "Error",
+                        "path": "foo/bar",
+                        "architecture": "arm64",
+                        "message": "This isn't good",
+                        "docUrl": "https://example.com/error",
+                    },
+                    {
+                        "severity": "Warning",
+                        "path": "pork/ham",
+                        "architecture": "arm64",
+                        "message": "This is fairly bad",
+                        "docUrl": "https://example.com/warning",
+                    },
+                ],
+            },
+            (
+                r"Notarization was rejected: Bad mojo\n"
+                r"\n"
+                r"    \* \(Error\) foo/bar \[arm64\]\n"
+                r"      This isn't good\n"
+                r"      https://example.com/error\n"
+                r"\n"
+                r"    \* \(Warning\) pork/ham \[arm64\]\n"
+                r"      This is fairly bad\n"
+                r"      https://example.com/warning"
+            ),
+        ),
+    ],
+)
+def test_notarization_rejected(
+    package_command,
+    first_app_with_binaries,
+    sekrit_identity,
+    tmp_path,
+    sleep_zero,
+    response,
+    error,
+):
+    """If notarization is rejected, an error is raised."""
+    # Create a pre-existing distribution artefact
+    create_file(
+        tmp_path / "base_path/dist/First App-0.0.1.dmg",
+        "distribution file",
+    )
+
+    # Select a codesigning identity
+    package_command.select_identity.return_value = sekrit_identity
+
+    # Mock the return values of subprocesses
+    submission_id = str(uuid.uuid4())
+    package_command.tools.subprocess.parse_output.side_effect = [
+        # notarytool history returns list with the app's submission ID
+        {
+            "history": [
+                {
+                    "id": str(uuid.uuid4()),
+                    "name": "Other App-1.2.3.dmg",
+                    "status": "Accepted",
+                },
+                {
+                    "id": submission_id,
+                    "name": "First App-0.0.1.dmg",
+                    "status": "In Progress",
+                },
+            ]
+        },
+        # notarytool log; 2 failures, then a successful result.
+        subprocess.CalledProcessError(
+            returncode=69,
+            cmd=["xcrun", "notarytool", "log"],
+        ),
+        subprocess.CalledProcessError(
+            returncode=69,
+            cmd=["xcrun", "notarytool", "log"],
+        ),
+        response,
+    ]
+
+    # Resume notarizaton
+    with pytest.raises(BriefcaseCommandError, match=error):
+        package_command._package_app(
+            first_app_with_binaries,
+            update=False,
+            packaging_format="dmg",
+            identity=sekrit_identity.id,
+            submission_id=submission_id,
+        )
+
+    # The calls to notarization tools were made
+    assert package_command.tools.subprocess.parse_output.mock_calls == [
+        # Retrieve notarization history to verify the submission ID
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "history",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                "--output-format",
+                "json",
+            ],
+        ),
+        # Check status 3 times
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "log",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                submission_id,
+            ],
+        ),
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "log",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                submission_id,
+            ],
+        ),
+        mock.call(
+            json_parser,
+            [
+                "xcrun",
+                "notarytool",
+                "log",
+                "--keychain-profile",
+                "briefcase-macOS-DEADBEEF",
+                submission_id,
+            ],
+        ),
+    ]
+
+    # No staple attempt is made.
+    package_command.tools.subprocess.run.assert_not_called()
diff --git a/tests/platforms/macOS/app/test_signing.py b/tests/platforms/macOS/app/test_signing.py
index 6042254e6..4327e7d82 100644
--- a/tests/platforms/macOS/app/test_signing.py
+++ b/tests/platforms/macOS/app/test_signing.py
@@ -218,6 +218,31 @@ def test_select_app_identity(dummy_command):
     assert dummy_command.input.prompts == ["> "]
 
 
+def test_select_app_identity_no_adhoc(dummy_command):
+    """Adhoc identities can be excluded from the list of options."""
+    # get_identities will return some options.
+    dummy_command.get_identities.return_value = {
+        "38EBD6F8903EC63C238B04C1067833814CE47CA3": "Developer ID Application: Example Corp Ltd (Z2K4383DLE)",
+        "11E77FB58F13F6108B38110D5D92233C58ED38C5": "iPhone Developer: Jane Smith (BXAH5H869S)",
+    }
+
+    # Return option 2
+    dummy_command.input.values = ["2"]
+
+    # Disallow adhoc identities. This will make the identities from get_identities the
+    # only available values.
+    result = dummy_command.select_identity(allow_adhoc=False)
+
+    # The identity will be second of the returned values
+    assert result == SigningIdentity(
+        id="11E77FB58F13F6108B38110D5D92233C58ED38C5",
+        name="iPhone Developer: Jane Smith (BXAH5H869S)",
+    )
+
+    # User input was solicited once
+    assert dummy_command.input.prompts == ["> "]
+
+
 def test_select_installer_identity(dummy_command):
     """The user can select from a list of installer identities."""
     # get_identities is invoked twice - once with app identities, and once with all identities.
@@ -634,7 +659,7 @@ def test_sign_app(
             ),
             sign_call(
                 tmp_path,
-                frameworks_path / "Extras.framework/Resources/extras.dylib",
+                frameworks_path / "Extras.framework/Versions/1.2/libs/extras.dylib",
                 identity=sekrit_identity,
             ),
             sign_call(