Repository: beeware/briefcase

Issue:
Add code notarization to the macOS package command
macOS apps can be code signed; but Catalina requires an additional *notarization* step.

The mechanics of the process are covered well by [this blog post](https://glyph.twistedmatrix.com/2018/01/shipping-pygame-mac-app.html). 

See #174 for an older ticket about this feature.

Discussion:

Patch:
diff --git a/changes/365.feature.rst b/changes/365.feature.rst
new file mode 100644
index 00000000..823bda8b
--- /dev/null
+++ b/changes/365.feature.rst
@@ -0,0 +1,1 @@
+macOS apps are now notarized as part of the packaging process.
diff --git a/docs/how-to/code-signing/macOS.rst b/docs/how-to/code-signing/macOS.rst
index f37bb9f5..1edaf831 100644
--- a/docs/how-to/code-signing/macOS.rst
+++ b/docs/how-to/code-signing/macOS.rst
@@ -10,7 +10,7 @@ which is required to distribute your application across MacOS and iOS devices.
 
 We will specifically focus on generating a `Developer ID Application identity
 <https://developer.apple.com/developer-id/>`__, which is used to distribute a
-*Mac application outside of the Mac App store*. However, the procedure for
+*macOS application outside of the Mac App store*. However, the procedure for
 creating all other types of identities is exactly the same. Once you familiarize
 yourself with the general process, you'll be able to create identities required
 to upload applications to the Mac or iOS App stores without much trouble.
@@ -57,7 +57,7 @@ Generating a certificate request on Keychain Access
 ---------------------------------------------------
 
 Now that you're set up with an Apple Developer ID, it's time to create a
-certificate *request*, which you'll then use to generate a valid Developer ID
+*certificate request*, which you'll then use to generate a valid Developer ID
 certificate.
 
 First, open the Keychain Access application on your Mac. At the top left of your
@@ -92,7 +92,6 @@ creates not only the file you have just saved, but also a private key in your
 Keychain, which will establish the validity of your actual Developer ID
 Application certificate later on.
 
-
 Creating a Developer ID Application Certificate
 -----------------------------------------------
 
@@ -103,7 +102,8 @@ Identifiers and Profiles":
 .. image:: images/Certificates_Identifiers_Profiles.png
    :width: 500
 
-When you land in the Certificates section, click the "+" symbol to create a new certificate:
+When you land in the Certificates section, click the "+" symbol to create a new
+certificate:
 
 .. image:: images/Create_certificate.png
    :width: 500
@@ -117,18 +117,21 @@ Later on, if you want to generate another code signing certificate for other
 purposes, such as uploading your application the App store, you'll simply have
 to choose a different type of a certificate on this page.
 
-**Note**: If you've been registered as an organization, there's a chance that
-the option to choose the Developer ID Application certificate is unavailable.
-This may happen if you're not assigned the role of the `Account Holder
-<https://developer.apple.com/documentation/security/notarizing_macos_software_before_distribution>`__.
-You can access and change these roles using `App Store Connect
-<https://appstoreconnect.apple.com/access/users>`__.
-
 .. image:: images/Choose_developerID_application.png
    :width: 500
 
-Click "Continue". In the next window, click "Choose file" and upload the
-Certificate Signing Request you have just generated on your Keychain:
+.. note::
+
+   If you've been registered as an organization, there's a chance that
+   the option to choose the Developer ID Application certificate is unavailable.
+   This may happen if you're not assigned the role of the `Account Holder
+   <https://developer.apple.com/documentation/security/notarizing_macos_software_before_distribution>`__.
+   You can access and change these roles using `App Store Connect
+   <https://appstoreconnect.apple.com/access/users>`__.
+
+Select "Developer ID Application" and click "Continue". In the next window,
+click "Choose file" and upload the Certificate Signing Request you have just
+generated on your Keychain:
 
 .. image:: images/Upload_certificate_request.png
    :width: 500
@@ -143,101 +146,41 @@ machine:
 The certificate should be of the format ``example.cer``. Once you download it,
 double-click to install it in your Keychain Access.
 
-Then open your Keychain, make sure you're in the ``login`` directy on the
-left-hand side, and open the window ``My Certificates``. You should see a
-certificate whose title starts with "Developer ID Application...".
+If you now open your Keychain, selected the ``login`` keychain on
+the left-hand side, and selec the ``My Certificates`` tab, you should see a
+certificate with the title "Developer ID Application: <your name>".
 
 Click on the certificate and make sure you see a note that reads ``This
-certificate is valid``. **Note**: In the example below, the certificate details
-have been erased:
+certificate is valid``.
 
 .. image:: images/Valid_certificate.png
    :width: 500
 
-Congratulations! You've just successfully installed the Developer ID Application
-certificate.
-
-
-Accessing the details of the Certificate on your Terminal
----------------------------------------------------------
-
-Finally, open your Terminal. You'll have to run a command that will fetch
-detailed information about all valid certificates for code signing on your local
-machine, including the Developer ID Application Certificate you have just
-created:
-
-.. tabs::
-
-  .. group-tab:: macOS
-
-    .. code-block:: bash
-
-      $ security find-identity -p basic -v
-
-
-The important part of the output is the following::
+.. note::
 
-    <Certificate ID> "Developer ID Application: <Name> (<Team ID>)"
+   In this screenshot, the certificate details have been redacted. Your
+   certificate should show expiration details, trust chains, and other
+   details about you, the certificate issuer (Apple), and the certificate.
 
-e.g::
-
-    A1B2C3D4E5F6G7H8I9J10K11L12M13N14O15P16R "Developer ID Application: Jane Doe (ABCD123456)"
-
-You'll need to keep note of two things:
-
- * **Certificate ID**: This should be a 40-unit string, which in the example is:
-   ``A1B2C3D4E5F6G7H8I9J10K11L12M13N14O15P16R``
-
- * **Team ID**: Will usually be a 10-unit string. Here, it's: ``ABCD123456``.
-
-
-Anticipating potential issues with the identity in the future
--------------------------------------------------------------
+Congratulations! You've just successfully installed the Developer ID Application
+certificate.
 
-It's also useful to keep in mind two potential issues related to MacOS code signing identities.
+.. admonition:: Keep this certificate safe!
 
- * First, the *specific type* of the certificate you have just created is quite
+   The *specific type* of the certificate you have just created is quite
    precious, and you should make sure to keep it safe. A single Developer ID
-   Application Certificate can be used to `sign, notarize and distribute multiple
-   applications <https://developer.apple.com/forums/thread/657993>`__ outside of
-   the Mac App store, which is why a `very limited number of them
-   <https://help.apple.com/xcode/mac/current/#/dev3a05256b8>`__ can be created on
-   a particular Developer Account. You should consider making a back up copy,
+   Application Certificate can be used to `sign, notarize and distribute
+   multiple applications <https://developer.apple.com/forums/thread/657993>`__
+   outside of the Mac App store, which is why a `very limited number of them
+   <https://help.apple.com/xcode/mac/current/#/dev3a05256b8>`__ can be created
+   on a particular Developer Account. You should consider making a backup copy,
    which will require you to export the certificate together with the associated
    private key from the Keychain. The procedure for doing so is `documented by
    Apple
    <https://support.apple.com/guide/keychain-access/import-and-export-keychain-items-kyca35961/mac>`__.
 
-
- * If you intend to create other types of code signing identities in the future,
-   it's also helpful to discuss `Apple's Worldwide Developer Relations (WWDR)
-   Intermediate Certificate <https://developer.apple.com/support/expiration/>`__.
-   While we didn't need to use it to create the Developer ID Application identity,
-   you'll need to have a WWDR certificate in your Keychain to create valid code
-   signing identities for other purposes, such as testing your applications with
-   the "Mac Development" certificate or uploading them to the App store with the
-   "Mac App Distribution" certificate.
-
-  The WWDR certificate should be automatically installed in your Keychain with
-  Xcode 11.4.1 or later. You should verify this by opening your Keychain, making
-  sure you're in the ``login`` directory on the left-hand side, and navigating
-  to the window ``My Certificates``. You should see a certificate called ``Apple
-  Worldwide Developer Relations Certification Authority`` whose **expiration
-  date is set to 2030**:
-
-  .. image:: images/WWDR_certificate.png
-     :width: 500
-
-  If you can't find this certificate in the Keychain, you can download it by
-  following the instructions on the `Apple Developer website
-  <https://developer.apple.com/support/expiration/>`__. Alhough Apple's
-  documentation may change in the future, the instructions are currently
-  displayed under the ``Taking Action`` section. There, you will find a link to
-  download the certificate directly or through `Apple's Certificate Authority
-  page <https://www.apple.com/certificateauthority/>`__. Once you download it,
-  make sure to install it in your Keychain.
-
-
 Next steps
 ----------
-Now it's time to start using the Developer ID Application Certificate to sign, notarize, and distribute your application!
+
+Now it's time to start using the Developer ID Application Certificate to sign,
+notarize, and distribute your application!
diff --git a/docs/reference/commands/package.rst b/docs/reference/commands/package.rst
index b250df0e..750fb85e 100644
--- a/docs/reference/commands/package.rst
+++ b/docs/reference/commands/package.rst
@@ -44,9 +44,10 @@ running::
     $ briefcase update
     $ briefcase package
 
+``-p <format>``, ``--packaging-format <format>``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
-publish
--------
+The format to use for packaging. The available packaging formats are platform dependent.
 
 ``--no-sign``
 ~~~~~~~~~~~~~
diff --git a/docs/reference/platforms/macOS/app.rst b/docs/reference/platforms/macOS/app.rst
index 98669ee3..d35bbfe0 100644
--- a/docs/reference/platforms/macOS/app.rst
+++ b/docs/reference/platforms/macOS/app.rst
@@ -9,12 +9,31 @@ the folder as an executable file, giving it an icon.
 ``.app`` bundles can be copied around as if they are a single file. They can
 also be compressed to reduce their size for transport.
 
+By default, apps will be both signed and notarized when they are packaged.
+
+The ``.app`` bundle is a distributable artefact. Alternatively, the ``.app``
+bundle can be packaged as a ``.dmg`` that contains the ``.app`` bundle. The
+default packaging format is ``.dmg``.
+
 Icon format
 ===========
 
-``.app`` bundles use ``.icns`` format icons.
+macOS ``.app`` bundles use ``.icns`` format icons.
 
 Splash Image format
 ===================
 
-``.app`` bundles do not support splash screens or installer images.
+macOS ``.app`` bundles do not support splash screens or installer images.
+
+Additional options
+==================
+
+The following options can be provided at the command line when packaging
+macOS apps.
+
+``--no-notarize``
+~~~~~~~~~~~~~~~~~
+
+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.
diff --git a/docs/reference/platforms/macOS/xcode.rst b/docs/reference/platforms/macOS/xcode.rst
index 9d8410de..26e8041e 100644
--- a/docs/reference/platforms/macOS/xcode.rst
+++ b/docs/reference/platforms/macOS/xcode.rst
@@ -6,6 +6,12 @@ Briefcase supports creating a full Xcode project for a macOS app. This project
 can then be used to build an actual app bundle, with the ``briefcase build``
 command or directly from Xcode.
 
+By default, apps will be both signed and notarized when they are packaged.
+
+The Xcode project will produce a ``.app`` bundle is a distributable artefact.
+Alternatively, this ``.app`` bundle can be packaged as a ``.dmg`` that contains
+the ``.app`` bundle. The default packaging format is ``.dmg``.
+
 Icon format
 ===========
 
@@ -24,3 +30,16 @@ Splash Image format
 ===================
 
 macOS Xcode projects do not support splash screens.
+
+Additional options
+==================
+
+The following options can be provided at the command line when packaging
+macOS apps.
+
+``--no-notarize``
+~~~~~~~~~~~~~~~~~
+
+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.
diff --git a/src/briefcase/integrations/subprocess.py b/src/briefcase/integrations/subprocess.py
index 4788500f..9b55df23 100644
--- a/src/briefcase/integrations/subprocess.py
+++ b/src/briefcase/integrations/subprocess.py
@@ -382,15 +382,15 @@ class Subprocess:
     def _log_output(self, output, stderr=None):
         """Log the output of the executed command."""
         if output:
-            self.command.logger.deep_debug("Command Output:")
+            self.command.logger.debug("Command Output:")
             for line in ensure_str(output).splitlines():
-                self.command.logger.deep_debug(f"    {line}")
+                self.command.logger.debug(f"    {line}")
 
         if stderr:
-            self.command.logger.deep_debug("Command Error Output (stderr):")
+            self.command.logger.debug("Command Error Output (stderr):")
             for line in ensure_str(stderr).splitlines():
-                self.command.logger.deep_debug(f"    {line}")
+                self.command.logger.debug(f"    {line}")
 
     def _log_return_code(self, return_code):
         """Log the output value of the executed command."""
-        self.command.logger.deep_debug(f"Return code: {return_code}")
+        self.command.logger.debug(f"Return code: {return_code}")
diff --git a/src/briefcase/platforms/macOS/__init__.py b/src/briefcase/platforms/macOS/__init__.py
index 9c000239..a005e764 100644
--- a/src/briefcase/platforms/macOS/__init__.py
+++ b/src/briefcase/platforms/macOS/__init__.py
@@ -1,7 +1,9 @@
 import os
+import re
 import subprocess
 import time
 from pathlib import Path
+from zipfile import ZipFile
 
 from briefcase.config import BaseConfig
 from briefcase.console import select_option
@@ -153,30 +155,43 @@ class macOSSigningMixin:
         if identity:
             try:
                 # Try to look up the identity as a hex checksum
-                return identities[identity]
+                identity_name = identities[identity]
+                return identity, identity_name
             except KeyError as e:
                 # Try to look up the identity as readable name
-                if identity in identities.values():
-                    return identity
-
-                # Not found
-                raise BriefcaseCommandError(
-                    f"Invalid code signing identity {identity!r}"
-                ) from e
+                try:
+                    reverse_lookup = {name: ident for ident, name in identities.items()}
+                    identity_id = reverse_lookup[identity]
+                    return identity_id, identity
+                except KeyError:
+                    # Not found as an ID or name
+                    raise BriefcaseCommandError(
+                        f"Invalid code signing identity {identity!r}"
+                    ) from e
 
         if len(identities) == 0:
             raise BriefcaseCommandError("No code signing identities are available.")
         elif len(identities) == 1:
-            identity = list(identities.items())[0][1]
+            identity, identity_name = list(identities.items())[0]
         else:
             self.input.prompt()
             self.input.prompt("Select code signing identity to use:")
             self.input.prompt()
-            selection = select_option(identities, input=self.input)
-            identity = identities[selection]
-            self.logger.info(f"selected {identity}")
+            identity = select_option(identities, input=self.input)
+            identity_name = identities[identity]
+            self.logger.info(
+                f"""
+In the future, you could specify this signing identity by running:
+
+    briefcase {self.command} macOS -i {identity}
+
+or
+    briefcase {self.command} macOS -i "{identity_name}"
+
+"""
+            )
 
-        return identity
+        return identity, identity_name
 
     def sign_file(self, path, identity, entitlements=None):
         """Code sign a file.
@@ -283,6 +298,20 @@ class macOSPackageMixin(macOSSigningMixin):
     def default_packaging_format(self):
         return "dmg"
 
+    def add_options(self, parser):
+        super().add_options(parser)
+        # We use store_const:False rather than store_false so that the
+        # "unspecified" value is None, rather than True, allowing for
+        # a "default behavior" interpretation with `--no-sign` or
+        # `--adhoc-sign` is specified
+        parser.add_argument(
+            "--no-notarize",
+            dest="notarize_app",
+            action="store_const",
+            const=False,
+            help="Disable notarization for the app",
+        )
+
     def verify_tools(self):
         if self.host_os != "Darwin":
             raise BriefcaseCommandError(
@@ -303,10 +332,158 @@ class macOSPackageMixin(macOSSigningMixin):
         # These are abstracted to enable testing without patching.
         self.dmgbuild = dmgbuild
 
+    def team_id_from_identity(self, identity_name):
+        """Extract the team ID from the full identity name.
+
+        The identity name will be in the form:
+            Some long identifying name (Team ID)
+
+        :param identity_name: The full identity name
+        :returns: The team ID string.
+        """
+        try:
+            return re.match(r".*\(([\dA-Z]*)\)", identity_name)[1]
+        except TypeError:
+            raise BriefcaseCommandError(
+                "Couldn't extract Team ID from signing identity {identity!r}"
+            )
+
+    def notarize(self, filename, team_id):
+        """Notarize a file.
+
+        Submits the file to Apple for notarization; if successful, staples the
+        notarization result onto the file.
+
+        If the file is a .app, it will be archived as a .zip for submission purposes.
+
+        :param filename: The file to notarize.
+        :param team_id: The team ID to
+        """
+        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"
+                    with ZipFile(archive_filename, "a") as archive:
+                        for path in filename.glob("**/*"):
+                            archive.write(
+                                path, arcname=path.relative_to(filename.parent)
+                            )
+            elif filename.suffix == ".dmg":
+                archive_filename = filename
+            else:
+                archive_filename = filename
+                raise RuntimeError(
+                    f"Don't know how to notarize a file of type {filename.suffix}"
+                )
+
+            profile = f"briefcase-macOS-{team_id}"
+            submitted = False
+            store_credentials = False
+            while not submitted:
+                if store_credentials:
+                    if not self.input.enabled:
+                        raise BriefcaseCommandError(
+                            f"""
+The keychain does not contain credentials for the profile {profile}.
+You can store these credentials by invoking:
+
+    xcrun notarytool store-credentials --team-id {team_id} profile
+"""
+                        )
+
+                    self.logger.warning(
+                        """
+The notarization process uses credentials stored on your system Keychain.
+You need to do this once for each signing certificate you use.
+
+The credentials are authenticated and stored using your Apple ID, using
+an app-specific Apple ID password. To generate an app-specific Apple ID
+password:
+
+  1. Sign into https://appleid.apple.com;
+  2. In the 'Sign-in and Security' section, click 'App-Specific Passwords';
+  3. Click on the '+' icon. You will need to provide an identifying name
+     for the password. You can pick any name that makes sense to you - the
+     name is only there so you can identify passwords. 'Briefcase' would be
+     one possible name.
+  4. Record the password somewhere safe.
+"""
+                    )
+                    try:
+                        self.subprocess.run(
+                            [
+                                "xcrun",
+                                "notarytool",
+                                "store-credentials",
+                                "--team-id",
+                                team_id,
+                                profile,
+                            ],
+                            check=True,
+                        )
+                    except subprocess.CalledProcessError as e:
+                        raise BriefcaseCommandError(
+                            f"Unable to store credentials for team ID {team_id}."
+                        ) from e
+
+                # Attempt the notarization
+                try:
+                    self.logger.info()
+                    self.subprocess.run(
+                        [
+                            "xcrun",
+                            "notarytool",
+                            "submit",
+                            os.fsdecode(archive_filename),
+                            "--keychain-profile",
+                            profile,
+                            "--wait",
+                        ],
+                        check=True,
+                    )
+                    submitted = True
+                except subprocess.CalledProcessError as e:
+                    # Error when submitting for notarization.
+                    # A return code of 69 (nice) indicates an issue with the
+                    # keychain profile. If store_credentials is already True,
+                    # then we've already tried to store them, so call the attempt
+                    # a fail
+                    if e.returncode == 69 and not store_credentials:
+                        store_credentials = True
+                    else:
+                        raise BriefcaseCommandError(
+                            f"Unable to submit {filename.relative_to(self.base_path)} for notarization."
+                        ) from e
+        finally:
+            # Clean up house; we don't need the archive any more.
+            if archive_filename != filename:
+                self.os.unlink(archive_filename)
+
+        try:
+            self.logger.info()
+            self.logger.info(
+                f"Stapling notarization onto {filename.relative_to(self.base_path)}..."
+            )
+            self.subprocess.run(
+                [
+                    "xcrun",
+                    "stapler",
+                    "staple",
+                    os.fsdecode(filename),
+                ],
+                check=True,
+            )
+        except subprocess.CalledProcessError:
+            raise BriefcaseCommandError(
+                f"Unable to staple notarization onto {filename.relative_to(self.base_path)}."
+            )
+
     def package_app(
         self,
         app: BaseConfig,
         sign_app=True,
+        notarize_app=None,
         identity=None,
         adhoc_sign=False,
         packaging_format="dmg",
@@ -315,16 +492,24 @@ class macOSPackageMixin(macOSSigningMixin):
         """Package an app bundle.
 
         :param app: The application to package
-        :param sign_app: Should the application be signed?
+        :param sign_app: Should the application be signed? Default: ``True``
+        :param notarize_app: Should the app be notarized? Default: ``True`` if the
+            app has been signed with a real identity; ``False`` if the app is
+            unsigned, or an ad-hoc signing identity has been used.
         :param identity: The code signing identity to use. This can be either
             the 40-digit hex checksum, or the string name of the identity.
             If unspecified, the user will be prompted for a code signing
-            identity. Ignored if ``sign_app`` is False.
-        :param adhoc_sign: If true, code will be signed with adhoc identity of "-"
-        :param packaging_format: The packaging format to use. Default is `dmg`.
+            identity. Ignored if ``sign_app`` is ``False``.
+        :param adhoc_sign: If ``True``, code will be signed with adhoc identity of "-"
+        :param packaging_format: The packaging format to use. Default is ``dmg``.
         """
         if sign_app:
             if adhoc_sign:
+                if notarize_app:
+                    raise BriefcaseCommandError(
+                        "Can't notarize an app with an adhoc signing identity"
+                    )
+
                 identity = "-"
 
                 self.logger.info()
@@ -332,14 +517,35 @@ class macOSPackageMixin(macOSSigningMixin):
                     "Signing app with adhoc identity...", prefix=app.app_name
                 )
             else:
-                identity = self.select_identity(identity=identity)
+                # If we're signing, and notarization isn't explicitly disabled,
+                # notarize by default.
+                if notarize_app is None:
+                    notarize_app = True
+
+                identity, identity_name = self.select_identity(identity=identity)
 
                 self.logger.info()
                 self.logger.info(
-                    f"Signing app with identity {identity}...", prefix=app.app_name
+                    f"Signing app with identity {identity_name}...", prefix=app.app_name
                 )
 
+                if notarize_app:
+                    team_id = self.team_id_from_identity(identity_name)
+
             self.sign_app(app=app, identity=identity)
+        else:
+            if notarize_app:
+                raise BriefcaseCommandError(
+                    "Can't notarize an app that hasn't been signed"
+                )
+
+        if packaging_format == "app":
+            if notarize_app:
+                self.logger.info(
+                    f"Notarizing app using team ID {team_id}...",
+                    prefix=app.app_name,
+                )
+                self.notarize(self.binary_path(app), team_id=team_id)
 
         if packaging_format == "dmg":
             self.logger.info()
@@ -392,11 +598,9 @@ class macOSPackageMixin(macOSSigningMixin):
                 # No installer background image provided
                 pass
 
-            dmg_path = os.fsdecode(
-                self.distribution_path(app, packaging_format=packaging_format)
-            )
+            dmg_path = self.distribution_path(app, packaging_format=packaging_format)
             self.dmgbuild.build_dmg(
-                filename=dmg_path,
+                filename=os.fsdecode(dmg_path),
                 volume_name=f"{app.formal_name} {app.version}",
                 settings=dmg_settings,
             )
@@ -406,3 +610,10 @@ class macOSPackageMixin(macOSSigningMixin):
                     dmg_path,
                     identity=identity,
                 )
+
+                if notarize_app:
+                    self.logger.info(
+                        f"Notarizing DMG with team ID {team_id}...",
+                        prefix=app.app_name,
+                    )
+                    self.notarize(dmg_path, team_id=team_id)


Test patch:
diff --git a/tests/integrations/docker/conftest.py b/tests/integrations/docker/conftest.py
index 46e87a87..e7336f91 100644
--- a/tests/integrations/docker/conftest.py
+++ b/tests/integrations/docker/conftest.py
@@ -23,6 +23,10 @@ def mock_docker(tmp_path):
     command.subprocess = Subprocess(command)
     command.subprocess._subprocess = MagicMock()
 
+    proc = MagicMock()
+    proc.returncode = 3
+    command.subprocess._subprocess.run.return_value = proc
+
     app = MagicMock()
     app.app_name = "myapp"
     app.sources = ["path/to/src/myapp", "other/stuff"]
diff --git a/tests/integrations/docker/test_Docker__run.py b/tests/integrations/docker/test_Docker__run.py
index 0cf7d3e8..b066e492 100644
--- a/tests/integrations/docker/test_Docker__run.py
+++ b/tests/integrations/docker/test_Docker__run.py
@@ -138,4 +138,5 @@ def test_simple_verbose_call(mock_docker, tmp_path, capsys):
         f"--volume {tmp_path / '.briefcase'}:/home/brutus/.briefcase:z "
         "briefcase/com.example.myapp:py3.X "
         "hello world\n"
+        ">>> Return code: 3\n"
     )
diff --git a/tests/integrations/subprocess/test_Subprocess__check_output.py b/tests/integrations/subprocess/test_Subprocess__check_output.py
index a689a05a..3233667d 100644
--- a/tests/integrations/subprocess/test_Subprocess__check_output.py
+++ b/tests/integrations/subprocess/test_Subprocess__check_output.py
@@ -121,7 +121,17 @@ def test_debug_call(mock_sub, capsys):
 
     mock_sub._subprocess.check_output.assert_called_with(["hello", "world"], text=True)
 
-    assert capsys.readouterr().out == "\n>>> Running Command:\n>>>     hello world\n"
+    expected_output = (
+        "\n"
+        ">>> Running Command:\n"
+        ">>>     hello world\n"
+        ">>> Command Output:\n"
+        ">>>     some output line 1\n"
+        ">>>     more output line 2\n"
+        ">>> Return code: 0\n"
+    )
+
+    assert capsys.readouterr().out == expected_output
 
 
 def test_debug_call_with_env(mock_sub, capsys):
@@ -144,6 +154,10 @@ def test_debug_call_with_env(mock_sub, capsys):
         ">>>     hello world\n"
         ">>> Environment:\n"
         ">>>     NewVar=NewVarValue\n"
+        ">>> Command Output:\n"
+        ">>>     some output line 1\n"
+        ">>>     more output line 2\n"
+        ">>> Return code: 0\n"
     )
 
     assert capsys.readouterr().out == expected_output
diff --git a/tests/integrations/subprocess/test_Subprocess__run.py b/tests/integrations/subprocess/test_Subprocess__run.py
index e6b565f0..76685597 100644
--- a/tests/integrations/subprocess/test_Subprocess__run.py
+++ b/tests/integrations/subprocess/test_Subprocess__run.py
@@ -119,9 +119,16 @@ def test_debug_call(mock_sub, capsys):
     mock_sub.run(["hello", "world"])
 
     mock_sub._subprocess.run.assert_called_with(["hello", "world"], text=True)
-    assert capsys.readouterr().out == (
-        "\n" ">>> Running Command:\n" ">>>     hello world\n"
+    # fmt: off
+    expected_output = (
+        "\n"
+        ">>> Running Command:\n"
+        ">>>     hello world\n"
+        ">>> Return code: 0\n"
     )
+    # fmt: on
+
+    assert capsys.readouterr().out == expected_output
 
 
 def test_debug_call_with_env(mock_sub, capsys):
@@ -144,6 +151,7 @@ def test_debug_call_with_env(mock_sub, capsys):
         ">>>     hello world\n"
         ">>> Environment:\n"
         ">>>     NewVar=NewVarValue\n"
+        ">>> Return code: 0\n"
     )
 
     assert capsys.readouterr().out == expected_output
diff --git a/tests/integrations/subprocess/test_Subprocess__stream_output.py b/tests/integrations/subprocess/test_Subprocess__stream_output.py
index 30f39f96..ae0f6fac 100644
--- a/tests/integrations/subprocess/test_Subprocess__stream_output.py
+++ b/tests/integrations/subprocess/test_Subprocess__stream_output.py
@@ -49,7 +49,16 @@ def test_output_debug(mock_sub, popen_process, capsys):
 
     mock_sub.stream_output("testing", popen_process)
 
-    assert capsys.readouterr().out == ("output line 1\n" "\n" "output line 3\n")
+    # fmt: off
+    expected_output = (
+        "output line 1\n"
+        "\n"
+        "output line 3\n"
+        ">>> Return code: -3\n"
+    )
+    # fmt: on
+    assert capsys.readouterr().out == expected_output
+
     mock_sub.cleanup.assert_called_once_with("testing", popen_process)
 
 
@@ -59,9 +68,16 @@ def test_output_deep_debug(mock_sub, popen_process, capsys):
 
     mock_sub.stream_output("testing", popen_process)
 
-    assert capsys.readouterr().out == (
-        "output line 1\n" "\n" "output line 3\n" ">>> Return code: -3\n"
+    # fmt: off
+    expected_output = (
+        "output line 1\n"
+        "\n"
+        "output line 3\n"
+        ">>> Return code: -3\n"
     )
+    # fmt: on
+    assert capsys.readouterr().out == expected_output
+
     mock_sub.cleanup.assert_called_once_with("testing", popen_process)
 
 
diff --git a/tests/platforms/macOS/app/test_package.py b/tests/platforms/macOS/app/test_package.py
index 0afe0135..610c3175 100644
--- a/tests/platforms/macOS/app/test_package.py
+++ b/tests/platforms/macOS/app/test_package.py
@@ -14,6 +14,7 @@ def package_command(tmp_path):
     command.select_identity = mock.MagicMock()
     command.sign_app = mock.MagicMock()
     command.sign_file = mock.MagicMock()
+    command.notarize = mock.MagicMock()
     command.dmgbuild = mock.MagicMock()
 
     return command
@@ -22,14 +23,73 @@ def package_command(tmp_path):
 def test_package_app(package_command, first_app_with_binaries, tmp_path, capsys):
     """A macOS App can be packaged."""
     # Select a codesigning identity
-    package_command.select_identity.return_value = "Sekrit identity (DEADBEEF)"
+    package_command.select_identity.return_value = (
+        "CAFEBEEF",
+        "Sekrit identity (DEADBEEF)",
+    )
 
-    # Package the app
+    # Package the app. Sign and notarize by default
     package_command.package_app(first_app_with_binaries)
 
     # A request has been made to sign the app
     package_command.sign_app.assert_called_once_with(
-        app=first_app_with_binaries, identity="Sekrit identity (DEADBEEF)"
+        app=first_app_with_binaries, identity="CAFEBEEF"
+    )
+
+    # The DMG has been built as expected
+    package_command.dmgbuild.build_dmg.assert_called_once_with(
+        filename=os.fsdecode(tmp_path / "macOS" / "First App-0.0.1.dmg"),
+        volume_name="First App 0.0.1",
+        settings={
+            "files": [
+                os.fsdecode(tmp_path / "macOS" / "app" / "First App" / "First App.app")
+            ],
+            "symlinks": {"Applications": "/Applications"},
+            "icon_locations": {
+                "First App.app": (75, 75),
+                "Applications": (225, 75),
+            },
+            "window_rect": ((600, 600), (350, 150)),
+            "icon_size": 64,
+            "text_size": 12,
+        },
+    )
+
+    # A request was made to sign the DMG as well.
+    # This ignores the calls that would have been made transitively
+    # by calling sign_app()
+    package_command.sign_file.assert_called_once_with(
+        tmp_path / "macOS" / "First App-0.0.1.dmg",
+        identity="CAFEBEEF",
+    )
+
+    # A request was made to notarize the DMG
+    package_command.notarize.assert_called_once_with(
+        tmp_path / "macOS" / "First App-0.0.1.dmg",
+        team_id="DEADBEEF",
+    )
+
+    # The app doesn't specify an app icon or installer icon, so there's no
+    # mention about the DMG installer icon in the console log.
+    assert "DMG installer icon" not in capsys.readouterr().out
+
+
+def test_package_app_no_notarization(
+    package_command, first_app_with_binaries, tmp_path, capsys
+):
+    """A macOS App can be packaged without notarization."""
+    # Select a codesigning identity
+    package_command.select_identity.return_value = (
+        "CAFEBEEF",
+        "Sekrit identity (DEADBEEF)",
+    )
+
+    # Package the app; sign by default, but disable notarization
+    package_command.package_app(first_app_with_binaries, notarize_app=False)
+
+    # A request has been made to sign the app
+    package_command.sign_app.assert_called_once_with(
+        app=first_app_with_binaries, identity="CAFEBEEF"
     )
 
     # The DMG has been built as expected
@@ -55,10 +115,13 @@ def test_package_app(package_command, first_app_with_binaries, tmp_path, capsys)
     # This ignores the calls that would have been made transitively
     # by calling sign_app()
     package_command.sign_file.assert_called_once_with(
-        os.fsdecode(tmp_path / "macOS" / "First App-0.0.1.dmg"),
-        identity="Sekrit identity (DEADBEEF)",
+        tmp_path / "macOS" / "First App-0.0.1.dmg",
+        identity="CAFEBEEF",
     )
 
+    # A request was made to notarize the DMG
+    package_command.notarize.assert_not_called()
+
     # The app doesn't specify an app icon or installer icon, so there's no
     # mention about the DMG installer icon in the console log.
     assert "DMG installer icon" not in capsys.readouterr().out
@@ -68,7 +131,10 @@ def test_package_app_sign_failure(package_command, first_app_with_binaries, tmp_
     """If the signing process can't be completed, an error is raised."""
 
     # Select a codesigning identity
-    package_command.select_identity.return_value = "Sekrit identity (DEADBEEF)"
+    package_command.select_identity.return_value = (
+        "CAFEBEEF",
+        "Sekrit identity (DEADBEEF)",
+    )
 
     # Raise an error when attempting to sign the app
     package_command.sign_app.side_effect = BriefcaseCommandError("Unable to code sign")
@@ -79,7 +145,7 @@ def test_package_app_sign_failure(package_command, first_app_with_binaries, tmp_
 
     # A request has been made to sign the app
     package_command.sign_app.assert_called_once_with(
-        app=first_app_with_binaries, identity="Sekrit identity (DEADBEEF)"
+        app=first_app_with_binaries, identity="CAFEBEEF"
     )
 
     # dmgbuild has not been called
@@ -91,23 +157,121 @@ def test_package_app_sign_failure(package_command, first_app_with_binaries, tmp_
     package_command.sign_file.assert_not_called()
 
 
-def test_package_app_no_sign(package_command, first_app_with_binaries, tmp_path):
-    """A macOS App can be packaged without signing."""
+def test_package_app_no_sign(package_command, first_app_with_binaries):
+    """A macOS App can be packaged without signing or notarization."""
+
+    # Package the app without code signing or notarization
+    package_command.package_app(
+        first_app_with_binaries,
+        sign_app=False,
+        notarize_app=False,
+    )
+
+    # No code signing or notarization has been performed.
+    assert package_command.select_identity.call_count == 0
+    assert package_command.sign_app.call_count == 0
+    assert package_command.sign_file.call_count == 0
+    assert package_command.notarize.call_count == 0
+
+
+def test_package_app_notarize_without_sign(package_command, first_app_with_binaries):
+    """A macOS App cannot be notarized if it wasn't signed."""
 
     # Package the app without code signing
-    package_command.package_app(first_app_with_binaries, sign_app=False)
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=r"Can't notarize an app that hasn't been signed",
+    ):
+        package_command.package_app(
+            first_app_with_binaries,
+            sign_app=False,
+            notarize_app=True,
+        )
+
+    # No code signing or notarization has been performed.
+    assert package_command.select_identity.call_count == 0
+    assert package_command.sign_app.call_count == 0
+    assert package_command.sign_file.call_count == 0
+    assert package_command.notarize.call_count == 0
+
+
+def test_package_app_notarize_without_sign_default_notariztion(
+    package_command, first_app_with_binaries
+):
+    """A macOS App will default to no notarization if it wasn't signed."""
 
-    # No code signing has been performed.
+    # Package the app without code signing; notarization will be disabled
+    # even though it isn't specified
+    package_command.package_app(
+        first_app_with_binaries,
+        sign_app=False,
+    )
+
+    # No code signing or notarization has been performed.
     assert package_command.select_identity.call_count == 0
     assert package_command.sign_app.call_count == 0
     assert package_command.sign_file.call_count == 0
+    assert package_command.notarize.call_count == 0
 
 
 def test_package_app_adhoc_sign(package_command, first_app_with_binaries, tmp_path):
     """A macOS App can be packaged and signed with adhoc identity."""
 
-    # Package the app with an adhoc identity
-    package_command.package_app(first_app_with_binaries, adhoc_sign=True)
+    # Package the app with an adhoc identity.
+    # Explicitly disable notarization (can't adhoc notarize an app)
+    package_command.package_app(
+        first_app_with_binaries,
+        adhoc_sign=True,
+        notarize_app=False,
+    )
+
+    # A request has been made to sign the app
+    package_command.sign_app.assert_called_once_with(
+        app=first_app_with_binaries, identity="-"
+    )
+
+    # The DMG has been built as expected
+    package_command.dmgbuild.build_dmg.assert_called_once_with(
+        filename=os.fsdecode(tmp_path / "macOS" / "First App-0.0.1.dmg"),
+        volume_name="First App 0.0.1",
+        settings={
+            "files": [
+                os.fsdecode(tmp_path / "macOS" / "app" / "First App" / "First App.app")
+            ],
+            "symlinks": {"Applications": "/Applications"},
+            "icon_locations": {
+                "First App.app": (75, 75),
+                "Applications": (225, 75),
+            },
+            "window_rect": ((600, 600), (350, 150)),
+            "icon_size": 64,
+            "text_size": 12,
+        },
+    )
+
+    # A request was made to sign the DMG as well.
+    # This ignores the calls that would have been made transitively
+    # by calling sign_app()
+    package_command.sign_file.assert_called_once_with(
+        tmp_path / "macOS" / "First App-0.0.1.dmg",
+        identity="-",
+    )
+
+    # No request was made to notarize
+    package_command.notarize.assert_not_called()
+
+
+def test_package_app_adhoc_sign_default_notarization(
+    package_command, first_app_with_binaries, tmp_path
+):
+    """An adhoc signed app is not notarized by default."""
+
+    # Package the app with an adhoc identity; notarization will
+    # be disabled as a default
+    package_command.package_app(
+        first_app_with_binaries,
+        adhoc_sign=True,
+    )
 
     # A request has been made to sign the app
     package_command.sign_app.assert_called_once_with(
@@ -137,23 +301,101 @@ def test_package_app_adhoc_sign(package_command, first_app_with_binaries, tmp_pa
     # This ignores the calls that would have been made transitively
     # by calling sign_app()
     package_command.sign_file.assert_called_once_with(
-        os.fsdecode(tmp_path / "macOS" / "First App-0.0.1.dmg"), identity="-"
+        tmp_path / "macOS" / "First App-0.0.1.dmg",
+        identity="-",
     )
 
+    # No request was made to notarize
+    package_command.notarize.assert_not_called()
 
-def test_package_app_no_dmg(package_command, first_app_with_binaries, tmp_path):
+
+def test_package_bare_app(package_command, first_app_with_binaries, tmp_path):
     """A macOS App can be packaged without building dmg."""
     # Select a code signing identity
-    package_command.select_identity.return_value = "Sekrit identity (DEADBEEF)"
+    package_command.select_identity.return_value = (
+        "CAFEBEEF",
+        "Sekrit identity (DEADBEEF)",
+    )
 
     # Package the app in app (not DMG) format
     package_command.package_app(first_app_with_binaries, packaging_format="app")
 
     # A request has been made to sign the app
     package_command.sign_app.assert_called_once_with(
-        app=first_app_with_binaries, identity="Sekrit identity (DEADBEEF)"
+        app=first_app_with_binaries, identity="CAFEBEEF"
+    )
+
+    # A request has been made to notarize the app
+    package_command.notarize.assert_called_once_with(
+        tmp_path / "macOS" / "app" / "First App" / "First App.app",
+        team_id="DEADBEEF",
+    )
+
+    # No dmg was built.
+    assert package_command.dmgbuild.build_dmg.call_count == 0
+
+    # If the DMG doesn't exist, it can't be signed either.
+    # This ignores the calls that would have been made transitively
+    # by calling sign_app()
+    assert package_command.sign_file.call_count == 0
+
+
+def test_package_bare_app_no_sign(package_command, first_app_with_binaries):
+    """A macOS App can be packaged without building dmg, and without
+    signing."""
+    # Select a code signing identity
+    package_command.select_identity.return_value = (
+        "CAFEBEEF",
+        "Sekrit identity (DEADBEEF)",
+    )
+
+    # Package the app in app (not DMG) format, disabling signing and notarization
+    package_command.package_app(
+        first_app_with_binaries,
+        packaging_format="app",
+        sign_app=False,
+        notarize_app=False,
+    )
+
+    # No request has been made to sign the app
+    package_command.sign_app.assert_not_called()
+
+    # No request has been made to notarize the app
+    package_command.notarize.assert_not_called()
+
+    # No dmg was built.
+    assert package_command.dmgbuild.build_dmg.call_count == 0
+
+    # If the DMG doesn't exist, it can't be signed either.
+    # This ignores the calls that would have been made transitively
+    # by calling sign_app()
+    assert package_command.sign_file.call_count == 0
+
+
+def test_package_bare_app_no_notarization(package_command, first_app_with_binaries):
+    """A macOS App can be packaged without building dmg, and without
+    notarization."""
+    # Select a code signing identity
+    package_command.select_identity.return_value = (
+        "CAFEBEEF",
+        "Sekrit identity (DEADBEEF)",
+    )
+
+    # Package the app in app (not DMG) format, disabling notarization
+    package_command.package_app(
+        first_app_with_binaries,
+        packaging_format="app",
+        notarize_app=False,
+    )
+
+    # A request has been made to sign the app
+    package_command.sign_app.assert_called_once_with(
+        app=first_app_with_binaries, identity="CAFEBEEF"
     )
 
+    # No request has been made to notarize the app
+    package_command.notarize.assert_not_called()
+
     # No dmg was built.
     assert package_command.dmgbuild.build_dmg.call_count == 0
 
@@ -170,8 +412,12 @@ def test_dmg_with_installer_icon(package_command, first_app_with_binaries, tmp_p
     with open(tmp_path / "pretty.icns", "wb") as f:
         f.write(b"A pretty installer icon")
 
-    # Package the app without signing
-    package_command.package_app(first_app_with_binaries, sign_app=False)
+    # Package the app without signing or notarization
+    package_command.package_app(
+        first_app_with_binaries,
+        sign_app=False,
+        notarize_app=False,
+    )
 
     # The DMG has been built as expected
     package_command.dmgbuild.build_dmg.assert_called_once_with(
@@ -202,8 +448,12 @@ def test_dmg_with_missing_installer_icon(
     # Specify an installer icon, but don't create the matching file.
     first_app_with_binaries.installer_icon = "pretty"
 
-    # Package the app without signing
-    package_command.package_app(first_app_with_binaries, sign_app=False)
+    # Package the app without signing or notarization
+    package_command.package_app(
+        first_app_with_binaries,
+        sign_app=False,
+        notarize_app=False,
+    )
 
     # The DMG has been built as expected
     package_command.dmgbuild.build_dmg.assert_called_once_with(
@@ -240,8 +490,12 @@ def test_dmg_with_app_installer_icon(
     with open(tmp_path / "pretty_app.icns", "wb") as f:
         f.write(b"A pretty app icon")
 
-    # Package the app without signing
-    package_command.package_app(first_app_with_binaries, sign_app=False)
+    # Package the app without signing or notarization
+    package_command.package_app(
+        first_app_with_binaries,
+        sign_app=False,
+        notarize_app=False,
+    )
 
     # The DMG has been built as expected
     package_command.dmgbuild.build_dmg.assert_called_once_with(
@@ -272,8 +526,12 @@ def test_dmg_with_missing_app_installer_icon(
     # Specify an app icon, but don't create the matching file.
     first_app_with_binaries.icon = "pretty_app"
 
-    # Package the app without signing
-    package_command.package_app(first_app_with_binaries, sign_app=False)
+    # Package the app without signing or notarization
+    package_command.package_app(
+        first_app_with_binaries,
+        sign_app=False,
+        notarize_app=False,
+    )
 
     # The DMG has been built as expected
     package_command.dmgbuild.build_dmg.assert_called_once_with(
@@ -310,8 +568,12 @@ def test_dmg_with_installer_background(
     with open(tmp_path / "pretty_background.png", "wb") as f:
         f.write(b"A pretty background")
 
-    # Package the app without signing
-    package_command.package_app(first_app_with_binaries, sign_app=False)
+    # Package the app without signing or notarization
+    package_command.package_app(
+        first_app_with_binaries,
+        sign_app=False,
+        notarize_app=False,
+    )
 
     # The DMG has been built as expected
     package_command.dmgbuild.build_dmg.assert_called_once_with(
@@ -342,8 +604,12 @@ def test_dmg_with_missing_installer_background(
     # Specify an installer background, but don't create the matching file.
     first_app_with_binaries.installer_background = "pretty_background"
 
-    # Package the app without signing
-    package_command.package_app(first_app_with_binaries, sign_app=False)
+    # Package the app without signing or notarization
+    package_command.package_app(
+        first_app_with_binaries,
+        sign_app=False,
+        notarize_app=False,
+    )
 
     # The DMG has been built as expected
     package_command.dmgbuild.build_dmg.assert_called_once_with(
diff --git a/tests/platforms/macOS/app/test_package__notarize.py b/tests/platforms/macOS/app/test_package__notarize.py
new file mode 100644
index 00000000..c4720522
--- /dev/null
+++ b/tests/platforms/macOS/app/test_package__notarize.py
@@ -0,0 +1,460 @@
+import os
+import subprocess
+from unittest import mock
+from unittest.mock import MagicMock
+
+import pytest
+
+from briefcase.exceptions import BriefcaseCommandError
+from briefcase.platforms.macOS.app import macOSAppPackageCommand
+
+
+@pytest.fixture
+def package_command(tmp_path):
+    command = macOSAppPackageCommand(base_path=tmp_path)
+    command.os = MagicMock()
+
+    command.subprocess.run = MagicMock()
+
+    return command
+
+
+@pytest.fixture
+def first_app_dmg(tmp_path):
+    dmg_path = tmp_path / "macOS" / "First App.dmg"
+    dmg_path.parent.mkdir(parents=True)
+    with dmg_path.open("w") as f:
+        f.write("DMG content here")
+
+    return dmg_path
+
+
+def test_notarize_app(package_command, first_app_with_binaries, tmp_path):
+    """An app can be notarized."""
+    app_path = tmp_path / "macOS" / "app" / "First App" / "First App.app"
+    archive_path = tmp_path / "macOS" / "app" / "First App" / "archive.zip"
+    package_command.notarize(app_path, team_id="DEADBEEF")
+
+    # As a result of mocking os.unlink, the zip archive won't be
+    # cleaned up, so we can test for it's existence, but also
+    # verify that it *would* have been deleted.
+    assert archive_path.exists()
+    package_command.os.unlink.assert_called_with(archive_path)
+
+    # The calls to notarize were made
+    package_command.subprocess.run.assert_has_calls(
+        [
+            # Submit *archive* for notarization
+            mock.call(
+                [
+                    "xcrun",
+                    "notarytool",
+                    "submit",
+                    os.fsdecode(archive_path),
+                    "--keychain-profile",
+                    "briefcase-macOS-DEADBEEF",
+                    "--wait",
+                ],
+                check=True,
+            ),
+            # Staple the result to the *app*
+            mock.call(
+                [
+                    "xcrun",
+                    "stapler",
+                    "staple",
+                    os.fsdecode(app_path),
+                ],
+                check=True,
+            ),
+        ]
+    )
+
+
+def test_notarize_dmg(package_command, first_app_dmg):
+    """A DMG can be notarized."""
+
+    package_command.notarize(first_app_dmg, team_id="DEADBEEF")
+
+    # The DMG didn't require an archive file, so unlink wasn't invoked.
+    package_command.os.unlink.assert_not_called()
+
+    # The calls to notarize were made
+    package_command.subprocess.run.assert_has_calls(
+        [
+            # Submit for notarization
+            mock.call(
+                [
+                    "xcrun",
+                    "notarytool",
+                    "submit",
+                    os.fsdecode(first_app_dmg),
+                    "--keychain-profile",
+                    "briefcase-macOS-DEADBEEF",
+                    "--wait",
+                ],
+                check=True,
+            ),
+            # Staple the result
+            mock.call(
+                [
+                    "xcrun",
+                    "stapler",
+                    "staple",
+                    os.fsdecode(first_app_dmg),
+                ],
+                check=True,
+            ),
+        ]
+    )
+
+
+def test_notarize_unknown_format(package_command, tmp_path):
+    """Attempting to notarize a file of unknown format raises an error."""
+    pkg_path = tmp_path / "macOS" / "First App.pkg"
+
+    # The notarization call will fail with an error
+    with pytest.raises(
+        RuntimeError,
+        match=r"Don't know how to notarize a file of type .pkg",
+    ):
+        package_command.notarize(pkg_path, team_id="DEADBEEF")
+
+
+def test_notarize_unknown_credentials(package_command, first_app_dmg):
+    """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.subprocess.run.side_effect = [
+        subprocess.CalledProcessError(
+            returncode=69,
+            cmd=["xcrun", "notarytool", "submit"],
+        ),  # Unknown credential failure
+        None,  # Successful credential storage
+        None,  # Successful notarization
+        None,  # Successful stapling
+    ]
+
+    package_command.notarize(first_app_dmg, team_id="DEADBEEF")
+
+    # The DMG didn't require an archive file, so unlink wasn't invoked.
+    package_command.os.unlink.assert_not_called()
+
+    # The calls to notarize were made
+    package_command.subprocess.run.assert_has_calls(
+        [
+            # Submit for notarization
+            mock.call(
+                [
+                    "xcrun",
+                    "notarytool",
+                    "submit",
+                    os.fsdecode(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,
+            ),
+            # Submit for notarization a second time
+            mock.call(
+                [
+                    "xcrun",
+                    "notarytool",
+                    "submit",
+                    os.fsdecode(first_app_dmg),
+                    "--keychain-profile",
+                    "briefcase-macOS-DEADBEEF",
+                    "--wait",
+                ],
+                check=True,
+            ),
+            # Staple the result
+            mock.call(
+                [
+                    "xcrun",
+                    "stapler",
+                    "staple",
+                    os.fsdecode(first_app_dmg),
+                ],
+                check=True,
+            ),
+        ]
+    )
+
+
+def test_credential_storage_failure(package_command, first_app_dmg):
+    """If credentials haven't been stored, and storage fails, an error is
+    raised."""
+    # Set up subprocess to fail on the first notarization attempt,
+    # then fail on the storage of credentials
+    package_command.subprocess.run.side_effect = [
+        subprocess.CalledProcessError(
+            returncode=69,
+            cmd=["xcrun", "notarytool", "submit"],
+        ),  # Unknown credential failure
+        subprocess.CalledProcessError(
+            returncode=1,
+            cmd=["xcrun", "notarytool", "store-credentials"],
+        ),  # Credential verification failed
+    ]
+
+    # The notarization call will fail with an error
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=r"Unable to store credentials for team ID DEADBEEF.",
+    ):
+        package_command.notarize(first_app_dmg, team_id="DEADBEEF")
+
+    # The DMG didn't require an archive file, so unlink wasn't invoked.
+    package_command.os.unlink.assert_not_called()
+
+    # The calls to notarize were made
+    package_command.subprocess.run.assert_has_calls(
+        [
+            # Submit for notarization
+            mock.call(
+                [
+                    "xcrun",
+                    "notarytool",
+                    "submit",
+                    os.fsdecode(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,
+            ),
+        ]
+    )
+
+
+def test_credential_storage_disabled_input(package_command, first_app_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.subprocess.run.side_effect = [
+        subprocess.CalledProcessError(
+            returncode=69,
+            cmd=["xcrun", "notarytool", "submit"],
+        ),  # Unknown credential failure
+    ]
+    # Disable console input
+    package_command.input.enabled = False
+
+    # The notarization call will fail with an error
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=r"The keychain does not contain credentials for the profile briefcase-macOS-DEADBEEF.",
+    ):
+        package_command.notarize(first_app_dmg, team_id="DEADBEEF")
+
+    # The DMG didn't require an archive file, so unlink wasn't invoked.
+    package_command.os.unlink.assert_not_called()
+
+    # The calls to notarize were made
+    package_command.subprocess.run.assert_has_calls(
+        [
+            # Submit for notarization
+            mock.call(
+                [
+                    "xcrun",
+                    "notarytool",
+                    "submit",
+                    os.fsdecode(first_app_dmg),
+                    "--keychain-profile",
+                    "briefcase-macOS-DEADBEEF",
+                    "--wait",
+                ],
+                check=True,
+            ),
+        ]
+    )
+
+
+def test_notarize_unknown_credentials_after_storage(package_command, first_app_dmg):
+    """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.subprocess.run.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
+    ]
+
+    # The notarization call will fail with an error
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=r"Unable to submit macOS[/\\]First App.dmg for notarization.",
+    ):
+        package_command.notarize(first_app_dmg, team_id="DEADBEEF")
+
+    # The DMG didn't require an archive file, so unlink wasn't invoked.
+    package_command.os.unlink.assert_not_called()
+
+    # The calls to notarize were made
+    package_command.subprocess.run.assert_has_calls(
+        [
+            # Submit for notarization
+            mock.call(
+                [
+                    "xcrun",
+                    "notarytool",
+                    "submit",
+                    os.fsdecode(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,
+            ),
+            # Submit for notarization a second time
+            mock.call(
+                [
+                    "xcrun",
+                    "notarytool",
+                    "submit",
+                    os.fsdecode(first_app_dmg),
+                    "--keychain-profile",
+                    "briefcase-macOS-DEADBEEF",
+                    "--wait",
+                ],
+                check=True,
+            ),
+        ]
+    )
+
+
+def test_notarization_failure_with_credentials(package_command, first_app_dmg):
+    """If the notarization process fails for a reason other than credentials,
+    an error is raised."""
+    # Set up subprocess to fail on the first notarization attempt
+    # for a reason other than credentials
+    package_command.subprocess.run.side_effect = [
+        subprocess.CalledProcessError(
+            returncode=42,
+            cmd=["xcrun", "notarytool", "submit"],
+        ),  # Notarization failure; error code 42 is a fake value
+    ]
+
+    # The notarization call will fail with an error
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=r"Unable to submit macOS[/\\]First App.dmg for notarization.",
+    ):
+        package_command.notarize(first_app_dmg, team_id="DEADBEEF")
+
+    # The DMG didn't require an archive file, so unlink wasn't invoked.
+    package_command.os.unlink.assert_not_called()
+
+    # The calls to notarize were made
+    package_command.subprocess.run.assert_has_calls(
+        [
+            # Submit for notarization
+            mock.call(
+                [
+                    "xcrun",
+                    "notarytool",
+                    "submit",
+                    os.fsdecode(first_app_dmg),
+                    "--keychain-profile",
+                    "briefcase-macOS-DEADBEEF",
+                    "--wait",
+                ],
+                check=True,
+            ),
+        ]
+    )
+
+
+def test_stapling_failure(package_command, first_app_dmg):
+    """If the stapling process fails, an error is raised."""
+    # Set up a failure in the stapling process
+    package_command.subprocess.run.side_effect = [
+        None,
+        subprocess.CalledProcessError(
+            returncode=42,
+            cmd=["xcrun", "stapler"],
+        ),  # Stapling failure; error code 42 is a fake value
+    ]
+
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=r"Unable to staple notarization onto macOS[/\\]First App.dmg",
+    ):
+        package_command.notarize(first_app_dmg, team_id="DEADBEEF")
+
+    # The DMG didn't require an archive file, so unlink wasn't invoked.
+    package_command.os.unlink.assert_not_called()
+
+    # The calls to notarize were made
+    package_command.subprocess.run.assert_has_calls(
+        [
+            # Submit for notarization
+            mock.call(
+                [
+                    "xcrun",
+                    "notarytool",
+                    "submit",
+                    os.fsdecode(first_app_dmg),
+                    "--keychain-profile",
+                    "briefcase-macOS-DEADBEEF",
+                    "--wait",
+                ],
+                check=True,
+            ),
+            # Staple the result
+            mock.call(
+                [
+                    "xcrun",
+                    "stapler",
+                    "staple",
+                    os.fsdecode(first_app_dmg),
+                ],
+                check=True,
+            ),
+        ]
+    )
diff --git a/tests/platforms/macOS/app/test_package__team_id_from_identity.py b/tests/platforms/macOS/app/test_package__team_id_from_identity.py
new file mode 100644
index 00000000..76a33e26
--- /dev/null
+++ b/tests/platforms/macOS/app/test_package__team_id_from_identity.py
@@ -0,0 +1,36 @@
+import pytest
+
+from briefcase.exceptions import BriefcaseCommandError
+from briefcase.platforms.macOS.app import macOSAppPackageCommand
+
+
+@pytest.fixture
+def package_command(tmp_path):
+    command = macOSAppPackageCommand(base_path=tmp_path)
+    return command
+
+
+@pytest.mark.parametrize(
+    "identity_name, team_id",
+    [
+        ("Developer ID Application: Jane Developer (DEADBEEF)", "DEADBEEF"),
+        ("Developer ID Application: Edwin (Buzz) Aldrin (DEADBEEF)", "DEADBEEF"),
+    ],
+)
+def test_team_id_from_identity(package_command, identity_name, team_id):
+    assert package_command.team_id_from_identity(identity_name) == team_id
+
+
+@pytest.mark.parametrize(
+    "identity_name",
+    [
+        "Developer ID Application: Jane Developer",
+        "DEADBEEF",
+    ],
+)
+def test_bad_identity(package_command, identity_name):
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=r"Couldn't extract Team ID from signing identity",
+    ):
+        package_command.team_id_from_identity(identity_name)
diff --git a/tests/platforms/macOS/app/test_signing.py b/tests/platforms/macOS/app/test_signing.py
index e820ffac..989cf6bb 100644
--- a/tests/platforms/macOS/app/test_signing.py
+++ b/tests/platforms/macOS/app/test_signing.py
@@ -14,6 +14,8 @@ from tests.utils import DummyConsole
 class DummySigningCommand(macOSAppMixin, macOSSigningMixin, BaseCommand):
     """A dummy command to expose code signing capapbilities."""
 
+    command = "sign"
+
     def __init__(self, base_path, **kwargs):
         super().__init__(base_path=base_path, **kwargs)
         self.input = DummyConsole()
@@ -108,7 +110,10 @@ def test_explicit_identity_checksum(dummy_command):
     # The identity will be the onethe user specified as an option.
     result = dummy_command.select_identity("11E77FB58F13F6108B38110D5D92233C58ED38C5")
 
-    assert result == "iPhone Developer: Jane Smith (BXAH5H869S)"
+    assert result == (
+        "11E77FB58F13F6108B38110D5D92233C58ED38C5",
+        "iPhone Developer: Jane Smith (BXAH5H869S)",
+    )
 
     # User input was not solicited
     assert dummy_command.input.prompts == []
@@ -125,7 +130,10 @@ def test_explicit_identity_name(dummy_command):
     # The identity will be the onethe user specified as an option.
     result = dummy_command.select_identity("iPhone Developer: Jane Smith (BXAH5H869S)")
 
-    assert result == "iPhone Developer: Jane Smith (BXAH5H869S)"
+    assert result == (
+        "11E77FB58F13F6108B38110D5D92233C58ED38C5",
+        "iPhone Developer: Jane Smith (BXAH5H869S)",
+    )
 
     # User input was not solicited
     assert dummy_command.input.prompts == []
@@ -157,7 +165,10 @@ def test_implied_identity(dummy_command):
     result = dummy_command.select_identity()
 
     # The identity will be the only option available.
-    assert result == "iPhone Developer: Jane Smith (BXAH5H869S)"
+    assert result == (
+        "11E77FB58F13F6108B38110D5D92233C58ED38C5",
+        "iPhone Developer: Jane Smith (BXAH5H869S)",
+    )
 
     # User input was not solicited
     assert dummy_command.input.prompts == []
@@ -177,7 +188,10 @@ def test_selected_identity(dummy_command):
     result = dummy_command.select_identity()
 
     # The identity will be the only option available.
-    assert result == "iPhone Developer: Jane Smith (BXAH5H869S)"
+    assert result == (
+        "11E77FB58F13F6108B38110D5D92233C58ED38C5",
+        "iPhone Developer: Jane Smith (BXAH5H869S)",
+    )
 
     # User input was solicited once
     assert dummy_command.input.prompts == ["> "]