Repository: beeware/briefcase

Issue:
Enable use of Briefcase for *only* signing and bundling, not app creation
### What is the problem or limitation you are having?

Via https://github.com/glyph/Encrust/issues/2#issuecomment-2608120375

It should be possible to use Briefcase's app signing, notarization and packaging tools without adopting Briefcase for the whole-of-app lifecycle.



### Describe the solution you'd like

On macOS, 
```
$ briefcase package --bundle-path "/path/to/My App.app" 
```
should sign, notarize and package the provided app as a DMG (or ZIP, or PKG) without needing to use Briefcase to *generate* `My App.app`

The same command should also work for Windows MSI and ZIP releases.

Usage on Linux System packages and Flatpaks *may* be possible.

It won't be possible to use this feature with Android, iOS or Web. These platforms should raise an error if `--bundle-path` is specified.

This will still require a `pyproject.toml`, but the set of keys required will be massively reduced.

### Describe alternatives you've considered

* Do nothing; continue to expect users to port their entire projects to Briefcase. This might be aided with better tools for converting other packaging tool configurations to Briefcase configurations.

### Additional context

_No response_


Discussion:

Patch:
diff --git a/changes/2326.feature.rst b/changes/2326.feature.rst
new file mode 100644
index 000000000..0519efcd6
--- /dev/null
+++ b/changes/2326.feature.rst
@@ -0,0 +1,1 @@
+Briefcase can now be used to sign, package and notarize apps that Briefcase didn't create. You can now use another tool to create a macOS, Windows or Linux application, and then use Briefcase to complete the process of turning that app into a signed and notarized release artefact.
diff --git a/docs/how-to/external-apps.rst b/docs/how-to/external-apps.rst
new file mode 100644
index 000000000..5da07abcd
--- /dev/null
+++ b/docs/how-to/external-apps.rst
@@ -0,0 +1,162 @@
+=======================
+Packaging external apps
+=======================
+
+By default, Briefcase assumes that you will be using Briefcase to manage the full app
+packaging life cycle. In this mode, your Briefcase configuration defines all your app's
+source code, dependencies, and resources; and Briefcase generates a distributable
+artefact that is appropriate for the target platform.
+
+However, Briefcase can *also* be used to package an application which
+has been constructed using another tool, such as `PyInstaller
+<https://pyinstaller.org/>`_ or `py2app <https://py2app.readthedocs.io/en/latest/>`_. In
+this mode of operation, Briefcase can be used to convert the app generated by
+those tools into an installer artefact, performing signing and notarization where
+necessary.
+
+Briefcase refers to this mode of Briefcase usage as packaging an "external app".
+
+Configuring an external app
+---------------------------
+
+The process of configuring an external app is mostly the same as a normal Briefcase app,
+with one important difference: you do *not* define a ``sources`` entry, but instead
+define a ``external_package_path``. The ``external_package_path`` should be the content
+produced by your build tool that you want to distribute to end users.
+
+macOS
+~~~~~
+
+On macOS, the ``external_package_path`` should be the location of the ``.app`` bundle
+that you want to distribute to users. The configured ``formal_name`` must match the name
+of the app bundle (e.g., ``Hello World.app`` should use a formal name of ``Hello
+World``); the ``app_name`` will be used for the shortcut installed in ``/usr/local/bin``
+when creating a :doc:`console app </how-to/cli-apps>`.
+
+Briefcase will sign and notarize the app bundle, as well as any DMG or PKG file that is
+produced.
+
+External macOS apps must be full macOS app bundles. Briefcase cannot be used to package
+standalone binaries.
+
+Linux
+~~~~~
+
+On Linux, the ``external_package_path`` should be a path that contains the root of a installation
+tree to be packaged. If you define a ``external_package_path`` of ``external``, it is assumed
+that ``external/usr/bin/<app_name>`` will be the executable for your application, which will
+be installed as ``/usr/bin/<app_name>``. Any other content that should be installed as
+part of your app should be included in the tree contained under ``external``.
+
+Although it is not possible to specify ``requires`` for external applications, external
+Linux applications *must* define ``system_runtime_requires``. These requirements are
+used to define system-level requirements in the generated system packages.
+
+Windows
+~~~~~~~
+
+On Windows, the ``external_package_path`` should be the directory that contains all the
+content to be installed into the end-user's Program Files directory. By default, it is
+assumed that the executable used to launch the app is named
+``<external_package_path>/<formal_name>.exe`` (or
+``<external_package_path>/<app_name>.exe`` for a :doc:`console app </how-to/cli-apps>`).
+If this is not the name or path to your executable, you can define
+``external_package_executable_path`` to define the path inside ``external_package_path``
+to your executable binary. For example, a ``external_package_path`` value of
+``external``, and a ``external_package_executable_path`` value of
+``somewhere/myapp.exe``, would define that ``external/somewhere/myapp.exe`` is the
+executable that should be installed as the application shortcut.
+
+Customizing packaging metadata
+------------------------------
+
+In the process of packaging an external app, Briefcase will generate an application
+template. The template is used to populate packaging metadata, installer and signing
+configuration files. The template that is used is identical to the one used for a normal
+Briefcase application; however, the packaged app will be removed from the template, and
+no attempt will be made to install application dependencies, resources, or a support
+package.
+
+The files generated by the template should *not* be modified by hand. If you need to
+customize the contents of these templated files, you should use Briefcase app
+configuration settings to define those changes. For example, on macOS if you need to add
+an entitlement for signing purposes, you should *not* modify the templated
+``Entitlements.plist`` file by hand - you should use the :ref:`entitlement setting
+<macos-entitlement>` in your ``pyproject.toml``.
+
+If Briefcase's configuration options are not sufficient for your requirements, you can
+either fork the template used by Briefcase and use the ``template`` option to point at
+your custom template; or you can :doc:`submit a pull request to Briefcase
+</how-to/contribute/code>` to support your customization use case.
+
+Packaging an external app
+-------------------------
+
+Once you have configured your external app, you can call ``briefcase package``. There is
+no need to invoke ``briefcase create``, ``briefcase build``, or any other Briefcase
+command:
+
+.. tabs::
+
+  .. group-tab:: macOS
+
+    .. code-block:: console
+
+      (venv) $ briefcase package
+
+      [helloworld] Packaging external content from /Users/brutus/helloworld/output/Hello World.app
+
+      [helloworld] Generating application template...
+      ...
+      [helloworld] Packaged dist/Hello World-0.0.1.dmg
+
+    The ``dist`` folder will contain the ``.dmg`` that that was created.
+
+  .. group-tab:: Linux
+
+    The output of the package step will be slightly different depending on
+    your Linux distribution; however, it will follow the following general format:
+
+    .. code-block:: console
+
+      (venv) $ briefcase package
+
+      [helloworld] Finalizing application configuration...
+      Targeting ubuntu:jammy (Vendor base debian)
+      Determining glibc version... done
+      Targeting glibc 2.35
+      Targeting Python3.10
+
+      [helloworld] Packaging external content from /Users/brutus/helloworld/output/linux-app
+
+      [helloworld] Generating application template...
+      ...
+      [helloworld] Packaged dist/helloworld_0.0.1-1~ubuntu-jammy_amd64.deb
+
+    The ``dist`` folder will contain the system package that was created.
+
+    Docker (and the ``--target`` option) *cannot* be used to package external apps for
+    different operating systems.
+
+  .. group-tab:: Windows
+
+    .. code-block:: doscon
+
+      (venv) C:\...>briefcase package
+
+      [helloworld] Packaging external content from Z:\projects\helloworld\output\windows-app
+
+      [helloworld] Generating application template...
+      ...
+      [helloworld] Packaged dist\Hello_World-0.0.1.msi
+
+    The ``dist`` folder will contain the installer that was created.
+
+Any arguments to ``briefcase package`` that are allowed for normal Briefcase apps will
+also be honored for external apps. See the :doc:`documentation of the Package command
+</reference/commands/package>` for details.
+
+Briefcase will re-generate the templated app content on every invocation of ``briefcase
+package``. On a second invocation of ``briefcase package`` for the same project, you
+will be prompted to overwrite the application. This will only overwrite the templated
+packaging metadata, not the application itself.
diff --git a/docs/how-to/index.rst b/docs/how-to/index.rst
index a905a6696..3dcdc3fab 100644
--- a/docs/how-to/index.rst
+++ b/docs/how-to/index.rst
@@ -17,7 +17,8 @@ stand alone.
    access-packaging-metadata
    ci
    cli-apps
+   external-apps
    publishing/index
-   upgrade-from-v0.2
    contribute/index
    internal/index
+   upgrade-from-v0.2
diff --git a/docs/reference/configuration.rst b/docs/reference/configuration.rst
index 3283babf3..61e9217c8 100644
--- a/docs/reference/configuration.rst
+++ b/docs/reference/configuration.rst
@@ -187,6 +187,10 @@ level, *and* platform level, the final set of sources will be the
 *concatenation* of sources from all levels, starting from least to most
 specific.
 
+The only time ``sources`` is *not* required is if you are is :doc:`packaging an external
+application </how-to/external-apps>`. If you are packaging an external application,
+``external_package_path`` must be defined, and ``sources`` *must not* be defined.
+
 Optional values
 ---------------
 
@@ -275,6 +279,36 @@ default value for this regular expression is ``^>>>>>>>>>> EXIT
 (?P<returncode>.*) <<<<<<<<<<$`` The regex will be compiled with the
 ``re.MULTILINE`` flag enabled.
 
+``external_package_path``
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. admonition:: Only for external apps
+
+    This setting is only required if you're using Briefcase to :doc:`package an external
+    application </how-to/external-apps>`. It is not required if you are using Briefcase
+    for the entire app creation process.
+
+The value of ``external_package_path`` defines the path to the root of a folder that
+will be packaged as an application. The contents of ``external_package_path`` is what
+will be shipped to the end user as the installed app.
+
+If ``external_package_path`` is defined, ``sources`` must *not* be defined.
+
+``external_package_executable_path``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. admonition:: Only for external apps
+
+    This setting is only allowed if you're using Briefcase to :doc:`package an external
+    application </how-to/external-apps>`. It is not allowed if you are using Briefcase
+    for the entire app creation process.
+
+The path to the binary that will be executed as part of the installed app. This is used
+when packaging external applications to establish the path to the shortcut that should
+be installed.
+
+This setting is only used on Windows.
+
 ``formal_name``
 ~~~~~~~~~~~~~~~
 
diff --git a/docs/reference/platforms/macOS/index.rst b/docs/reference/platforms/macOS/index.rst
index ad04aba9e..10deeb74b 100644
--- a/docs/reference/platforms/macOS/index.rst
+++ b/docs/reference/platforms/macOS/index.rst
@@ -96,6 +96,8 @@ Application configuration
 The following options can be added to the ``tool.briefcase.app.<appname>.macOS``
 section of your ``pyproject.toml`` file.
 
+.. _macos-entitlement:
+
 ``entitlement``
 ~~~~~~~~~~~~~~~
 
diff --git a/docs/spelling_wordlist b/docs/spelling_wordlist
index e33f2d60a..e7db4fd08 100644
--- a/docs/spelling_wordlist
+++ b/docs/spelling_wordlist
@@ -90,6 +90,7 @@ subdirectory
 subfolders
 submodule
 subprocesses
+templated
 Tk
 Tkinter
 tl
diff --git a/src/briefcase/commands/base.py b/src/briefcase/commands/base.py
index df046d5fc..71a8f49f4 100644
--- a/src/briefcase/commands/base.py
+++ b/src/briefcase/commands/base.py
@@ -441,6 +441,56 @@ def unbuilt_executable_path(self, app) -> Path:
 
         return self.binary_executable_path(app).parent / f"Stub{suffix}"
 
+    def bundle_package_path(self, app):
+        """The root path of the content in the bundle generated by Briefcase
+        that will be packaged for distribution.
+
+        This may be overridden if the user manually specifies external_package_path.
+
+        :param app: The app config
+        """
+        # We raise NotImplementedError(), rather than making the method an
+        # abstractmethod so that we don't have to define this method on platforms that
+        # don't support external packaging. If this method is ever invoked on a platform
+        # that doesn't support external packaging, we've got a problem.
+        raise NotImplementedError()
+
+    def package_path(self, app):
+        """The root path of the content that will be packaged for distribution.
+
+        :param app: The app config
+        """
+        if app.external_package_path:
+            return Path(app.external_package_path).absolute().resolve()
+        else:
+            return self.bundle_package_path(app)
+
+    def bundle_package_executable_path(self, app):
+        """The path, relative to the package path generated by Briefcase, of the
+        binary in the packaged app that is executed as the app.
+
+        This may be overridden if the user manually specifies a
+        external_package_executable_path.
+
+        :param app: The app config
+        """
+        # We raise NotImplementedError(), rather than making the method an
+        # abstractmethod so that we don't have to define this method on platforms that
+        # don't support external packaging. If this method is ever invoked on a platform
+        # that doesn't support external packaging, we've got a problem.
+        raise NotImplementedError()
+
+    def package_executable_path(self, app):
+        """The path, relative to the package path, of the binary that is
+        executed as the app.
+
+        :param app: The app config
+        """
+        if app.external_package_executable_path:
+            return app.external_package_executable_path
+        else:
+            return self.bundle_package_executable_path(app)
+
     def briefcase_toml(self, app: AppConfig) -> dict[str, ...]:
         """Load the ``briefcase.toml`` file provided by the app template.
 
@@ -655,6 +705,27 @@ def finalize(self, app: AppConfig | None = None, test_mode: bool = False):
                 self.finalize_app_config(app)
                 delattr(app, "__draft__")
 
+                if app.external_package_path:
+                    # Package path is defined
+                    if app.sources is not None:
+                        # sources is not defined
+                        raise BriefcaseConfigError(
+                            f"{app.app_name!r} is declared as an external app, but also "
+                            "defines 'sources'. External apps (apps defining 'external_package_path') "
+                            "cannot define sources."
+                        )
+                elif app.sources is None:
+                    # Neither sources or package_path is defined
+                    raise BriefcaseConfigError(
+                        f"{app.app_name!r} does not define either 'sources' or 'external_package_path'."
+                    )
+                else:
+                    # sources is defined, package_path is not
+                    if app.external_package_executable_path:
+                        raise BriefcaseConfigError(
+                            f"{app.app_name!r} defines 'external_package_executable_path', but not 'external_package_path'."
+                        )
+
     def verify_app(self, app: AppConfig):
         """Verify the app is compatible and the app tools are available.
 
diff --git a/src/briefcase/commands/build.py b/src/briefcase/commands/build.py
index f77b991e4..d9476f515 100644
--- a/src/briefcase/commands/build.py
+++ b/src/briefcase/commands/build.py
@@ -56,6 +56,12 @@ def _build_app(
         :param update_stub: Should the stub binary be updated?
         :param no_update: Should automated updates be disabled?
         """
+        if app.external_package_path:
+            raise BriefcaseCommandError(
+                f"{app.app_name!r} is declared as an external app. External apps "
+                "(apps defining 'external_package_path') cannot be built."
+            )
+
         if not self.bundle_path(app).exists():
             state = self.create_command(app, **options)
         elif (
diff --git a/src/briefcase/commands/create.py b/src/briefcase/commands/create.py
index 36fa85b48..16bbfb8ae 100644
--- a/src/briefcase/commands/create.py
+++ b/src/briefcase/commands/create.py
@@ -918,53 +918,72 @@ def create_app(self, app: AppConfig, **options):
         if bundle_path.exists():
             self.console.info()
             confirm = self.console.input_boolean(
-                f"Application {app.app_name!r} already exists; overwrite", default=False
+                f"The directory {self.bundle_path(app).relative_to(self.base_path)} "
+                "already exists; overwrite",
+                default=False,
             )
             if not confirm:
                 self.console.error(
-                    f"Aborting creation of app {app.app_name!r}; existing application will not be overwritten."
+                    f"Aborting creation of app {app.app_name!r}; "
+                    "existing application template will not be overwritten."
                 )
                 return
-            self.console.info("Removing old application bundle...", prefix=app.app_name)
+            self.console.info(
+                "Removing old application template...", prefix=app.app_name
+            )
             self.tools.shutil.rmtree(bundle_path)
 
         self.console.info("Generating application template...", prefix=app.app_name)
         self.generate_app_template(app=app)
 
-        self.console.info("Installing support package...", prefix=app.app_name)
-        self.install_app_support_package(app=app)
+        # External apps (apps that define 'external_package_path') need the packaging metadata
+        # from the template, but not the app content, dependencies, support package etc.
+        if app.external_package_path:
+            self.console.info("Removing generated app content...", prefix=app.app_name)
+            self.tools.shutil.rmtree(self.bundle_package_path(app))
 
-        try:
-            # If the platform uses a stub binary, the template will define a binary
-            # revision. If this template configuration item doesn't exist, no stub
-            # binary is required.
-            self.stub_binary_revision(app)
-        except KeyError:
-            pass
+            self.console.info(
+                "Created configuration for an externally packaged app "
+                f"in {bundle_path.relative_to(self.base_path)}",
+                prefix=app.app_name,
+            )
         else:
-            self.console.info("Installing stub binary...", prefix=app.app_name)
-            self.install_stub_binary(app=app)
+            self.console.info("Installing support package...", prefix=app.app_name)
+            self.install_app_support_package(app=app)
+
+            try:
+                # If the platform uses a stub binary, the template will define a binary
+                # revision. If this template configuration item doesn't exist, no stub
+                # binary is required.
+                self.stub_binary_revision(app)
+            except KeyError:
+                pass
+            else:
+                self.console.info("Installing stub binary...", prefix=app.app_name)
+                self.install_stub_binary(app=app)
 
-        # Verify the app after the app template and support package
-        # are in place since the app tools may be dependent on them.
-        self.verify_app(app)
+            # Verify the app after the app template and support package
+            # are in place since the app tools may be dependent on them.
+            self.verify_app(app)
 
-        self.console.info("Installing application code...", prefix=app.app_name)
-        self.install_app_code(app=app)
+            self.console.info("Installing application code...", prefix=app.app_name)
+            self.install_app_code(app=app)
 
-        self.console.info("Installing requirements...", prefix=app.app_name)
-        self.install_app_requirements(app=app)
+            self.console.info("Installing requirements...", prefix=app.app_name)
+            self.install_app_requirements(app=app)
 
-        self.console.info("Installing application resources...", prefix=app.app_name)
-        self.install_app_resources(app=app)
+            self.console.info(
+                "Installing application resources...", prefix=app.app_name
+            )
+            self.install_app_resources(app=app)
 
-        self.console.info("Removing unneeded app content...", prefix=app.app_name)
-        self.cleanup_app_content(app=app)
+            self.console.info("Removing unneeded app content...", prefix=app.app_name)
+            self.cleanup_app_content(app=app)
 
-        self.console.info(
-            f"Created {bundle_path.relative_to(self.base_path)}",
-            prefix=app.app_name,
-        )
+            self.console.info(
+                f"Created {bundle_path.relative_to(self.base_path)}",
+                prefix=app.app_name,
+            )
 
     def verify_tools(self):
         """Verify that the tools needed to run this command exist.
diff --git a/src/briefcase/commands/package.py b/src/briefcase/commands/package.py
index 5cebcfd1f..39c6c9d18 100644
--- a/src/briefcase/commands/package.py
+++ b/src/briefcase/commands/package.py
@@ -12,6 +12,7 @@
 class PackageCommand(BaseCommand):
     command = "package"
     description = "Package an app for distribution."
+    supports_external_packaging = False
 
     ADHOC_SIGN_HELP = "Ignored; signing is not supported"
     IDENTITY_HELP = "Ignored; signing is not supported"
@@ -78,10 +79,27 @@ def _package_app(
         :param update: Should the application be updated (and rebuilt) first?
         :param packaging_format: The format of the packaging artefact to create.
         """
-
         template_file = self.bundle_path(app)
         binary_file = self.binary_path(app)
-        if not template_file.exists():
+
+        if app.external_package_path:
+            if not self.supports_external_packaging:
+                raise BriefcaseCommandError(
+                    f"Briefcase cannot package external {self.platform} apps "
+                    f"in {self.output_format} format."
+                )
+
+            self.console.info(
+                f"Packaging external content from {self.package_path(app)}",
+                prefix=app.app_name,
+            )
+
+            # A minimal template is required to provide packaging configuration files
+            # and other metadata. We *always* generate the template to ensure that
+            # packaging metadata is up-to-date. If the app has been packaged before,
+            # it will be necessary to confirm deletion of the old folder.
+            state = self.create_command(app, **options)
+        elif not template_file.exists():
             state = self.create_command(app, **options)
             state = self.build_command(app, **full_options(state, options))
         elif update:
diff --git a/src/briefcase/commands/run.py b/src/briefcase/commands/run.py
index 426381755..6163d0fc2 100644
--- a/src/briefcase/commands/run.py
+++ b/src/briefcase/commands/run.py
@@ -318,6 +318,12 @@ def __call__(
 
         self.verify_app(app)
 
+        if app.external_package_path:
+            raise BriefcaseCommandError(
+                f"{app.app_name!r} is declared as an external app. External apps "
+                "(apps defining 'external_package_path') cannot be run."
+            )
+
         state = self.run_app(
             app,
             passthrough=[] if passthrough is None else passthrough,
diff --git a/src/briefcase/commands/update.py b/src/briefcase/commands/update.py
index eb84facac..372715a62 100644
--- a/src/briefcase/commands/update.py
+++ b/src/briefcase/commands/update.py
@@ -43,6 +43,12 @@ def update_app(
         :param update_stub: Should stub binary be updated?
         """
 
+        if app.external_package_path:
+            raise BriefcaseCommandError(
+                f"{app.app_name!r} is declared as an external app. External apps "
+                "(apps defining 'external_package_path') cannot be updated."
+            )
+
         if not self.bundle_path(app).exists():
             self.console.error(
                 "Application does not exist; call create first!", prefix=app.app_name
diff --git a/src/briefcase/config.py b/src/briefcase/config.py
index 91b1d1db6..7659880be 100644
--- a/src/briefcase/config.py
+++ b/src/briefcase/config.py
@@ -337,8 +337,8 @@ def __init__(
         version,
         bundle,
         description,
-        sources,
         license,
+        sources=None,
         formal_name=None,
         url=None,
         author=None,
@@ -355,6 +355,8 @@ def __init__(
         long_description=None,
         console_app=False,
         requirement_installer_args: list[str] | None = None,
+        external_package_path: str | None = None,
+        external_package_executable_path: str | None = None,
         **kwargs,
     ):
         super().__init__(**kwargs)
@@ -387,6 +389,9 @@ def __init__(
         self.requirement_installer_args = (
             [] if requirement_installer_args is None else requirement_installer_args
         )
+        self.external_package_path = external_package_path
+        self.external_package_executable_path = external_package_executable_path
+
         self.test_mode: bool = False
 
         if not is_valid_app_name(self.app_name):
@@ -417,20 +422,21 @@ def __init__(
                 "see https://www.python.org/dev/peps/pep-0440/ for details."
             )
 
-        # Sources list doesn't include any duplicates
-        source_modules = {source.rsplit("/", 1)[-1] for source in self.sources}
-        if len(self.sources) != len(source_modules):
-            raise BriefcaseConfigError(
-                f"The `sources` list for {self.app_name!r} contains duplicated "
-                "package names."
-            )
+        if self.sources:
+            # Sources list doesn't include any duplicates
+            source_modules = {source.rsplit("/", 1)[-1] for source in self.sources}
+            if len(self.sources) != len(source_modules):
+                raise BriefcaseConfigError(
+                    f"The `sources` list for {self.app_name!r} contains duplicated "
+                    "package names."
+                )
 
-        # There is, at least, a source for the app module
-        if self.module_name not in source_modules:
-            raise BriefcaseConfigError(
-                f"The `sources` list for {self.app_name!r} does not include a "
-                f"package named {self.module_name!r}."
-            )
+            # There is, at least, a source for the app module
+            if self.module_name not in source_modules:
+                raise BriefcaseConfigError(
+                    f"The `sources` list for {self.app_name!r} does not include a "
+                    f"package named {self.module_name!r}."
+                )
 
     def __repr__(self):
         return f"<{self.bundle_identifier} v{self.version} AppConfig>"
@@ -479,7 +485,7 @@ def package_name(self):
     def PYTHONPATH(self):
         """The PYTHONPATH modifications needed to run this app."""
         paths = []
-        sources = self.sources
+        sources = self.sources.copy() if self.sources else []
         if self.test_mode and self.test_sources:
             sources.extend(self.test_sources)
 
diff --git a/src/briefcase/platforms/linux/system.py b/src/briefcase/platforms/linux/system.py
index e637fff44..702025a84 100644
--- a/src/briefcase/platforms/linux/system.py
+++ b/src/briefcase/platforms/linux/system.py
@@ -4,6 +4,7 @@
 import re
 import subprocess
 import sys
+import tarfile
 from pathlib import Path
 
 from briefcase.commands import (
@@ -40,6 +41,7 @@ class LinuxSystemPassiveMixin(LinuxMixin):
     supported_host_os_reason = (
         "Linux system projects can only be built on Linux, or on macOS using Docker."
     )
+    supports_external_packaging = True
 
     @property
     def use_docker(self):
@@ -88,6 +90,9 @@ def project_path(self, app):
     def binary_path(self, app):
         return self.project_path(app) / "usr/bin" / app.app_name
 
+    def bundle_package_path(self, app):
+        return self.project_path(app)
+
     def rpm_tag(self, app):
         if app.target_vendor == "fedora":
             return f"fc{app.target_codename}"
@@ -150,6 +155,12 @@ def finalize_app_config(self, app: AppConfig):
         if not self.use_docker:
             app.target_image = f"{app.target_vendor}:{app.target_codename}"
         else:
+            if app.external_package_path:
+                raise BriefcaseCommandError(
+                    "Briefcase can't currently use Docker to package "
+                    "external apps as Linux system packages."
+                )
+
             # If we're building for Arch, and Docker does user mapping, we can't build,
             # because Arch won't let makepkg run as root. Docker on macOS *does* map the
             # user, but introducing a step-down user doesn't alter behavior, so we can
@@ -967,7 +978,7 @@ def _package_deb(self, app: AppConfig, **kwargs):
 
         # Write the Debian metadata control file.
         with self.console.wait_bar("Write Debian package control file..."):
-            DEBIAN_path = self.project_path(app) / "DEBIAN"
+            DEBIAN_path = self.package_path(app) / "DEBIAN"
 
             if DEBIAN_path.exists():
                 self.tools.shutil.rmtree(DEBIAN_path)
@@ -1014,7 +1025,7 @@ def _package_deb(self, app: AppConfig, **kwargs):
                         "dpkg-deb",
                         "--build",
                         "--root-owner-group",
-                        f"{app.app_name}-{app.version}",
+                        self.package_path(app),
                     ],
                     check=True,
                     cwd=self.bundle_path(app),
@@ -1026,7 +1037,7 @@ def _package_deb(self, app: AppConfig, **kwargs):
 
             # Move the deb file to its final location
             self.tools.shutil.move(
-                self.bundle_path(app) / f"{app.app_name}-{app.version}.deb",
+                self.package_path(app).parent / f"{self.package_path(app).name}.deb",
                 self.distribution_path(app),
             )
 
@@ -1129,8 +1140,8 @@ def _package_rpm(self, app: AppConfig, **kwargs):  # pragma: no-cover-if-is-wind
                 # in <app_name> (sub)directories (e.g., /usr/bin/<app_name> or
                 # /usr/share/man/man1/<app_name>.1.gz) will be included, but paths
                 # *not* cleaned up, as they're part of more general system structures.
-                for filename in sorted(self.project_path(app).glob("**/*")):
-                    path = filename.relative_to(self.project_path(app))
+                for filename in sorted(self.package_path(app).glob("**/*")):
+                    path = filename.relative_to(self.package_path(app))
 
                     if filename.is_dir():
                         if app.app_name in path.parts:
@@ -1159,16 +1170,18 @@ def _package_rpm(self, app: AppConfig, **kwargs):  # pragma: no-cover-if-is-wind
                     f.write(c.read())
 
         with self.console.wait_bar("Building source archive..."):
-            self.tools.shutil.make_archive(
-                rpmbuild_path / "SOURCES" / f"{app.app_name}-{app.version}",
-                format="gztar",
-                root_dir=self.bundle_path(app),
-                base_dir=f"{app.app_name}-{app.version}",
-            )
+            with tarfile.open(
+                rpmbuild_path / f"SOURCES/{self.bundle_package_path(app).name}.tar.gz",
+                "w:gz",
+            ) as archive:
+                archive.add(
+                    self.package_path(app),
+                    arcname=self.bundle_package_path(app).name,
+                )
 
         with self.console.wait_bar("Building RPM package..."):
             try:
-                # Build the dpkg.
+                # Build the rpm.
                 self.tools[app].app_context.run(
                     [
                         "rpmbuild",
@@ -1231,12 +1244,14 @@ def _package_pkg(self, app: AppConfig, **kwargs):  # pragma: no-cover-if-is-wind
 
         # Build the source archive
         with self.console.wait_bar("Building source archive..."):
-            self.tools.shutil.make_archive(
-                pkgbuild_path / f"{app.app_name}-{app.version}",
-                format="gztar",
-                root_dir=self.bundle_path(app),
-                base_dir=f"{app.app_name}-{app.version}",
-            )
+            with tarfile.open(
+                pkgbuild_path / f"{self.bundle_package_path(app).name}.tar.gz",
+                "w:gz",
+            ) as archive:
+                archive.add(
+                    self.package_path(app),
+                    arcname=self.bundle_package_path(app).name,
+                )
 
         # Write the arch PKGBUILD file.
         with self.console.wait_bar("Write PKGBUILD file..."):
diff --git a/src/briefcase/platforms/macOS/__init__.py b/src/briefcase/platforms/macOS/__init__.py
index f6fd1d32a..a66e46f0d 100644
--- a/src/briefcase/platforms/macOS/__init__.py
+++ b/src/briefcase/platforms/macOS/__init__.py
@@ -82,6 +82,9 @@ class macOSMixin:
     # 0.3.20 introduced a framework-based support package.
     platform_target_version = "0.3.20"
 
+    def bundle_package_path(self, app) -> Path:
+        return self.binary_path(app)
+
     def is_icloud_synced(self, path: Path) -> bool:
         """Determine if a path is on an iCloud drive.
 
@@ -155,6 +158,17 @@ def generate_app_template(self, app: AppConfig):
 
         :param app: The config object for the app
         """
+        # Before we generate the app template, make sure the package path and formal
+        # name match. This will always be the case for internal apps; they might not be
+        # aligned for external apps. We can't do this in verify, because app
+        # verification occurs after the template is generated.
+        if self.package_path(app).name != f"{app.formal_name}.app":
+            raise BriefcaseCommandError(
+                "The app bundle referenced by external_package_path "
+                f"({self.package_path(app).name})\n"
+                f"does not match the formal name of the app ({app.formal_name!r}).\n"
+            )
+
         super().generate_app_template(app=app)
         # If we discover we're on iCloud during app creation, we can clean up the app
         # folder. This *may* return a false negative (i.e., not accurately detect that
@@ -741,7 +755,7 @@ def sign_app(
         :param app: The app to sign
         :param identity: The signing identity to use
         """
-        bundle_path = self.binary_path(app)
+        bundle_path = self.package_path(app)
         resources_path = bundle_path / "Contents/Resources"
         frameworks_path = bundle_path / "Contents/Frameworks"
 
@@ -833,7 +847,7 @@ def notarization_path(self, app: AppConfig) -> Path:
             # 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)
+            return self.package_path(app)
         else:
             return self.distribution_path(app)
 
@@ -1433,9 +1447,9 @@ def finalize_package_zip(self, app: AppConfig):
         """Finalize the zip packaging process."""
         # Build the final archive for distribution
         with self.console.wait_bar(
-            f"Building final distribution archive for {self.binary_path(app).name}..."
+            f"Building final distribution archive for {self.package_path(app).name}..."
         ):
-            self.ditto_archive(self.binary_path(app), self.distribution_path(app))
+            self.ditto_archive(self.package_path(app), self.distribution_path(app))
 
     def package_pkg(
         self,
@@ -1478,11 +1492,11 @@ def package_pkg(
         # the products you want to install. So - we need to copy the built app to a
         # "clean" packaging location.
         with self.console.wait_bar("Copying app into products folder..."):
-            installed_app_path = installer_path / "root" / self.binary_path(app).name
+            installed_app_path = installer_path / "root" / self.package_path(app).name
             if installed_app_path.exists():
                 self.tools.shutil.rmtree(installed_app_path)
             self.tools.shutil.copytree(
-                self.binary_path(app),
+                self.package_path(app),
                 installed_app_path,
                 # Ensure that symlinks are preserved in the duplication.
                 symlinks=True,
@@ -1499,7 +1513,7 @@ def package_pkg(
                             "BundleIsRelocatable": False,
                             "BundleIsVersionChecked": True,
                             "BundleOverwriteAction": "upgrade",
-                            "RootRelativeBundlePath": self.binary_path(app).name,
+                            "RootRelativeBundlePath": self.package_path(app).name,
                         }
                     ],
                     components_plist,
@@ -1586,10 +1600,10 @@ def package_dmg(
 
         with self.console.wait_bar(f"Building {dist_path.name}..."):
             dmg_settings = {
-                "files": [os.fsdecode(self.binary_path(app))],
+                "files": [os.fsdecode(self.package_path(app))],
                 "symlinks": {"Applications": "/Applications"},
                 "icon_locations": {
-                    f"{app.formal_name}.app": (75, 75),
+                    self.package_path(app).name: (75, 75),
                     "Applications": (225, 75),
                 },
                 "window_rect": ((600, 600), (350, 150)),
diff --git a/src/briefcase/platforms/macOS/app.py b/src/briefcase/platforms/macOS/app.py
index b8dbb73b7..d650ee307 100644
--- a/src/briefcase/platforms/macOS/app.py
+++ b/src/briefcase/platforms/macOS/app.py
@@ -25,6 +25,7 @@
 
 class macOSAppMixin(macOSMixin):
     output_format = "app"
+    supports_external_packaging = True
 
     def project_path(self, app):
         return self.binary_path(app) / "Contents"
diff --git a/src/briefcase/platforms/windows/__init__.py b/src/briefcase/platforms/windows/__init__.py
index 41f5802d4..c0d821f71 100644
--- a/src/briefcase/platforms/windows/__init__.py
+++ b/src/briefcase/platforms/windows/__init__.py
@@ -18,13 +18,17 @@ class WindowsMixin:
     supported_host_os = {"Windows"}
     supported_host_os_reason = "Windows applications can only be built on Windows."
 
-    def binary_path(self, app):
+    def bundle_package_executable_path(self, app):
         if app.console_app:
-            return self.bundle_path(app) / self.packaging_root / f"{app.app_name}.exe"
+            return f"{app.app_name}.exe"
         else:
-            return (
-                self.bundle_path(app) / self.packaging_root / f"{app.formal_name}.exe"
-            )
+            return f"{app.formal_name}.exe"
+
+    def bundle_package_path(self, app):
+        return self.bundle_path(app) / self.packaging_root
+
+    def binary_path(self, app):
+        return self.package_path(app) / self.package_executable_path(app)
 
     def distribution_path(self, app):
         suffix = "zip" if app.packaging_format == "zip" else "msi"
@@ -99,6 +103,7 @@ def output_format_template_context(self, app: AppConfig):
             "version_triple": version_triple,
             "guid": str(guid),
             "install_scope": install_scope,
+            "binary_path": self.package_executable_path(app),
         }
 
     def _cleanup_app_support_package(self, support_path):
@@ -381,7 +386,7 @@ def _package_msi(self, app):
                     [
                         self.tools.wix.heat_exe,
                         "dir",
-                        self.packaging_root,
+                        self.package_path(app),
                         "-nologo",  # Don't display startup text
                         "-gg",  # Generate GUIDs
                         "-sfrag",  # Suppress fragment generation for directories
@@ -418,7 +423,7 @@ def _package_msi(self, app):
                         "WixUIExtension",
                         "-arch",
                         "x64",
-                        f"-dSourceDir={self.packaging_root}",
+                        f"-dSourceDir={self.package_path(app)}",
                         f"{app.app_name}.wxs",
                         f"{app.app_name}-manifest.wxs",
                     ],
@@ -457,7 +462,7 @@ def _package_zip(self, app):
 
         self.console.info("Building zip file...", prefix=app.app_name)
         with self.console.wait_bar("Packing..."):
-            source = self.bundle_path(app) / self.packaging_root  # /src
+            source = self.package_path(app)
             zip_root = f"{app.formal_name}-{app.version}"
 
             with ZipFile(self.distribution_path(app), "w", ZIP_DEFLATED) as archive:
diff --git a/src/briefcase/platforms/windows/app.py b/src/briefcase/platforms/windows/app.py
index 83772a84e..1de179d80 100644
--- a/src/briefcase/platforms/windows/app.py
+++ b/src/briefcase/platforms/windows/app.py
@@ -18,6 +18,7 @@
 class WindowsAppMixin(WindowsMixin):
     output_format = "app"
     packaging_root = Path("src")
+    supports_external_packaging = True
 
     def project_path(self, app):
         return self.bundle_path(app)


Test patch:
diff --git a/tests/commands/base/conftest.py b/tests/commands/base/conftest.py
index e5e86ec65..c7c81f0a2 100644
--- a/tests/commands/base/conftest.py
+++ b/tests/commands/base/conftest.py
@@ -32,6 +32,12 @@ def add_options(self, parser):
     def binary_path(self, app):
         raise NotImplementedError()
 
+    def bundle_package_path(self, app):
+        return self.bundle_path(app) / "src/package"
+
+    def bundle_package_executable_path(self, app):
+        return f"internal/{app.app_name}.exe"
+
     def verify_host(self):
         super().verify_host()
         self.actions.append(("verify-host",))
diff --git a/tests/commands/base/test_finalize.py b/tests/commands/base/test_finalize.py
index 98f001f48..f97ff48fe 100644
--- a/tests/commands/base/test_finalize.py
+++ b/tests/commands/base/test_finalize.py
@@ -1,6 +1,7 @@
 import pytest
 
 from briefcase.config import AppConfig
+from briefcase.exceptions import BriefcaseConfigError
 
 from .conftest import DummyCommand
 
@@ -137,3 +138,38 @@ def test_finalize_single_repeat(base_command, first_app, second_app):
     # First app is no longer in draft mode; second is
     assert not hasattr(first_app, "__draft__")
     assert hasattr(second_app, "__draft__")
+
+
+def test_external_and_internal(base_command, first_app):
+    """If an app provides both sources and external_package_path, an error is raised."""
+    first_app.external_package_path = "path/to/package"
+
+    with pytest.raises(
+        BriefcaseConfigError,
+        match=r"'first' is declared as an external app, but also defines 'sources'",
+    ):
+        base_command.finalize(first_app)
+
+
+def test_not_external_or_internal(base_command, first_app):
+    """If an app provides neither sources or external_package_path, an error is
+    raised."""
+    first_app.sources = None
+
+    with pytest.raises(
+        BriefcaseConfigError,
+        match=r"'first' does not define either 'sources' or 'external_package_path'.",
+    ):
+        base_command.finalize(first_app)
+
+
+def test_binary_path_internal_app(base_command, first_app):
+    """If an internal app defines external_package_executable_path, an error is
+    raised."""
+    first_app.external_package_executable_path = "internal/app.exe"
+
+    with pytest.raises(
+        BriefcaseConfigError,
+        match=r"'first' defines 'external_package_executable_path', but not 'external_package_path'",
+    ):
+        base_command.finalize(first_app)
diff --git a/tests/commands/base/test_parse_config.py b/tests/commands/base/test_parse_config.py
index 25d9aaf60..e8970992a 100644
--- a/tests/commands/base/test_parse_config.py
+++ b/tests/commands/base/test_parse_config.py
@@ -22,10 +22,10 @@ def test_incomplete_global_config(base_command):
         """
         [tool.briefcase]
         version = "1.2.3"
-        description = "A sample app"
         license.file = "LICENSE"
 
         [tool.briefcase.app.my-app]
+        description = "A sample app"
     """,
     )
 
@@ -38,7 +38,7 @@ def test_incomplete_global_config(base_command):
 
 def test_incomplete_config(base_command):
     """If the configuration is missing a required argument, an error is raised."""
-    # Provide a configuration that is missing `bundle`, a required attribute
+    # Provide a configuration that is missing `description`, a required attribute
     filename = base_command.base_path / "pyproject.toml"
     create_file(
         filename,
@@ -47,7 +47,6 @@ def test_incomplete_config(base_command):
         project_name = "Sample project"
         version = "1.2.3"
         bundle = "com.example"
-        description = "A sample app"
         license.file = "LICENSE"
 
         [tool.briefcase.app.my-app]
@@ -56,7 +55,7 @@ def test_incomplete_config(base_command):
 
     with pytest.raises(
         BriefcaseConfigError,
-        match=r"Configuration for 'my-app' is incomplete \(missing 'sources'\)",
+        match=r"Configuration for 'my-app' is incomplete \(missing 'description'\)",
     ):
         base_command.parse_config(filename, {})
 
diff --git a/tests/commands/base/test_properties.py b/tests/commands/base/test_properties.py
index bc1c716d8..3cbea8352 100644
--- a/tests/commands/base/test_properties.py
+++ b/tests/commands/base/test_properties.py
@@ -1,3 +1,5 @@
+from pathlib import Path
+
 from .conftest import DummyCommand
 
 
@@ -11,6 +13,48 @@ def test_bundle_path(base_command, my_app, tmp_path):
     assert bundle_path == tmp_path / "base_path/build/my-app/tester/dummy"
 
 
+def test_package_path(base_command, my_app, tmp_path):
+    package_path = base_command.package_path(my_app)
+    bundle_package_path = base_command.bundle_package_path(my_app)
+
+    assert package_path == tmp_path / "base_path/build/my-app/tester/dummy/src/package"
+    assert (
+        bundle_package_path
+        == tmp_path / "base_path/build/my-app/tester/dummy/src/package"
+    )
+
+
+def test_external_package_path(base_command, my_app, tmp_path):
+    my_app.external_package_path = "path/to/package"
+
+    package_path = base_command.package_path(my_app)
+    bundle_package_path = base_command.bundle_package_path(my_app)
+
+    assert package_path == (Path.cwd() / "path/to/package")
+    assert (
+        bundle_package_path
+        == tmp_path / "base_path/build/my-app/tester/dummy/src/package"
+    )
+
+
+def test_package_executable_path(base_command, my_app, tmp_path):
+    package_executable_path = base_command.package_executable_path(my_app)
+    bundle_package_executable_path = base_command.bundle_package_executable_path(my_app)
+
+    assert package_executable_path == "internal/my-app.exe"
+    assert bundle_package_executable_path == "internal/my-app.exe"
+
+
+def test_external_package_executable_path(base_command, my_app, tmp_path):
+    my_app.external_package_executable_path = "alternate/the_app.exe"
+
+    package_executable_path = base_command.package_executable_path(my_app)
+    bundle_package_executable_path = base_command.bundle_package_executable_path(my_app)
+
+    assert package_executable_path == "alternate/the_app.exe"
+    assert bundle_package_executable_path == "internal/my-app.exe"
+
+
 def test_create_command(base_command):
     # Check for a property of the created command class.
     assert base_command.create_command.description == "Test Create"
diff --git a/tests/commands/build/test_call.py b/tests/commands/build/test_call.py
index 5dcfa3e9c..3f00878d6 100644
--- a/tests/commands/build/test_call.py
+++ b/tests/commands/build/test_call.py
@@ -1356,3 +1356,25 @@ def test_build_app_all_flags(build_command, first_app, second_app):
         # First app is built in test mode
         ("build", "first", True, {"update_state": "first"}),
     ]
+
+
+def test_build_external_app(build_command, first_app, second_app):
+    """If the user requests a build an external app, an error is raised."""
+    # Add an apps
+    build_command.apps = {
+        "first": first_app,
+    }
+
+    # Make first_app an external app
+    first_app.sources = None
+    first_app.external_package_path = "path/to/package"
+
+    # Configure no command line options
+    options, _ = build_command.parse_options([])
+
+    # Run the build command
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=r"'first' is declared as an external app",
+    ):
+        build_command(**options)
diff --git a/tests/commands/create/conftest.py b/tests/commands/create/conftest.py
index b89a01949..fb30e9962 100644
--- a/tests/commands/create/conftest.py
+++ b/tests/commands/create/conftest.py
@@ -87,6 +87,12 @@ def exe_name(self, app_name=None):
     def binary_path(self, app):
         return self.bundle_path(app) / self.exe_name(app.formal_name)
 
+    def bundle_package_path(self, app):
+        return self.bundle_path(app) / "src/package"
+
+    def bundle_package_executable_path(self, app):
+        return f"internal/{app.app_name}.exe"
+
     # Hard code the python version to make testing easier.
     @property
     def python_version_tag(self):
@@ -164,6 +170,10 @@ def generate_app_template(self, app):
 
         # A mock version of template generation.
         create_file(self.bundle_path(app) / "new", "new template!")
+        create_file(
+            self.bundle_path(app) / "src/package/README",
+            "The packaged app goes here",
+        )
 
     def install_app_support_package(self, app):
         self.actions.append(("support", app.app_name))
@@ -240,9 +250,8 @@ def myapp():
 
 @pytest.fixture
 def bundle_path(myapp, tmp_path):
-    # Return the bundle path for the app; however, as a side effect,
-    # ensure that the app, and app_packages target directories
-    # exist, and the briefcase index file has been created.
+    # Return the bundle path for the app; however, as a side effect, ensure that the app
+    # and package target directories exist.
     bundle_path = tmp_path / "base_path/build" / myapp.app_name / "tester/dummy"
     (bundle_path / "path/to/app").mkdir(parents=True, exist_ok=True)
 
diff --git a/tests/commands/create/test_call.py b/tests/commands/create/test_call.py
index ff53d25f4..4db08adf2 100644
--- a/tests/commands/create/test_call.py
+++ b/tests/commands/create/test_call.py
@@ -57,6 +57,10 @@ def test_create(tracking_create_command, tmp_path):
     assert (tmp_path / "base_path/build/first/tester/dummy/new").exists()
     assert (tmp_path / "base_path/build/second/tester/dummy/new").exists()
 
+    # This includes the package path
+    assert (tmp_path / "base_path/build/first/tester/dummy/src/package").exists()
+    assert (tmp_path / "base_path/build/second/tester/dummy/src/package").exists()
+
 
 def test_create_single(tracking_create_command, tmp_path):
     """The create command can be called to create a single app from the config."""
@@ -85,6 +89,10 @@ def test_create_single(tracking_create_command, tmp_path):
     assert (tmp_path / "base_path/build/first/tester/dummy/new").exists()
     assert not (tmp_path / "base_path/build/second/tester/dummy/new").exists()
 
+    # This includes the package path
+    assert (tmp_path / "base_path/build/first/tester/dummy/src/package").exists()
+    assert not (tmp_path / "base_path/build/second/tester/dummy/src/package").exists()
+
 
 # Parametrize both --apps/-a flags
 @pytest.mark.parametrize("app_flags", ["--app", "-a"])
@@ -180,3 +188,30 @@ def test_create_app_all_flags(tracking_create_command):
         ("resources", "first"),
         ("cleanup", "first"),
     ]
+
+
+def test_create_external(tracking_create_command, tmp_path):
+    """The create command can be called on an external app."""
+    app = tracking_create_command.apps["first"]
+    app.sources = None
+    app.external_package_path = "path/to/package"
+
+    tracking_create_command(app=app)
+
+    # The right sequence of things will be done.
+    # This means `generate`, but nothing else
+    assert tracking_create_command.actions == [
+        # Host OS is verified
+        ("verify-host",),
+        # Tools are verified
+        ("verify-tools",),
+        # App configs have been finalized
+        ("finalize-app-config", "first"),
+        # Create the first app
+        ("generate", "first"),
+    ]
+
+    # New app content has been created
+    assert (tmp_path / "base_path/build/first/tester/dummy/new").exists()
+    # ... but the package path has been removed
+    assert not (tmp_path / "base_path/build/first/tester/dummy/src/package").exists()
diff --git a/tests/commands/create/test_create_app.py b/tests/commands/create/test_create_app.py
index a91f63d48..b3f7bed58 100644
--- a/tests/commands/create/test_create_app.py
+++ b/tests/commands/create/test_create_app.py
@@ -39,7 +39,8 @@ def test_create_existing_app_overwrite(tracking_create_command, tmp_path):
     tracking_create_command.console.values = ["y"]
 
     # Generate an app in the location.
-    bundle_path = tmp_path / "base_path/build/first/tester/dummy"
+    base_path = tmp_path / "base_path"
+    bundle_path = base_path / "build/first/tester/dummy"
     bundle_path.mkdir(parents=True)
     with (bundle_path / "original").open("w", encoding="utf-8") as f:
         f.write("original template!")
@@ -48,7 +49,7 @@ def test_create_existing_app_overwrite(tracking_create_command, tmp_path):
 
     # Input was required by the user
     assert tracking_create_command.console.prompts == [
-        "Application 'first' already exists; overwrite [y/N]? "
+        f"The directory {bundle_path.relative_to(base_path)} already exists; overwrite [y/N]? "
     ]
 
     # The right sequence of things will be done
@@ -75,7 +76,8 @@ def test_create_existing_app_no_overwrite(tracking_create_command, tmp_path):
     # Answer no when asked
     tracking_create_command.console.values = ["n"]
 
-    bundle_path = tmp_path / "base_path/build/first/tester/dummy"
+    base_path = tmp_path / "base_path"
+    bundle_path = base_path / "build/first/tester/dummy"
     bundle_path.mkdir(parents=True)
     with (bundle_path / "original").open("w", encoding="utf-8") as f:
         f.write("original template!")
@@ -83,7 +85,7 @@ def test_create_existing_app_no_overwrite(tracking_create_command, tmp_path):
 
     # Input was required by the user
     assert tracking_create_command.console.prompts == [
-        "Application 'first' already exists; overwrite [y/N]? "
+        f"The directory {bundle_path.relative_to(base_path)} already exists; overwrite [y/N]? "
     ]
 
     # No app creation actions will be performed
@@ -101,7 +103,8 @@ def test_create_existing_app_no_overwrite_default(tracking_create_command, tmp_p
     # Answer '' (i.e., just press return) when asked
     tracking_create_command.console.values = [""]
 
-    bundle_path = tmp_path / "base_path/build/first/tester/dummy"
+    base_path = tmp_path / "base_path"
+    bundle_path = base_path / "build/first/tester/dummy"
     bundle_path.mkdir(parents=True)
     with (bundle_path / "original").open("w", encoding="utf-8") as f:
         f.write("original template!")
@@ -110,7 +113,7 @@ def test_create_existing_app_no_overwrite_default(tracking_create_command, tmp_p
 
     # Input was required by the user
     assert tracking_create_command.console.prompts == [
-        "Application 'first' already exists; overwrite [y/N]? "
+        f"The directory {bundle_path.relative_to(base_path)} already exists; overwrite [y/N]? "
     ]
 
     # And no actions were necessary
diff --git a/tests/commands/create/test_generate_app_template.py b/tests/commands/create/test_generate_app_template.py
index 74629836b..7e12f7109 100644
--- a/tests/commands/create/test_generate_app_template.py
+++ b/tests/commands/create/test_generate_app_template.py
@@ -44,6 +44,8 @@ def full_context():
         "document_types": {},
         "license": {"file": "LICENSE"},
         "requirement_installer_args": [],
+        "external_package_path": None,
+        "external_package_executable_path": None,
         "test_mode": False,
         # Properties of the generating environment
         "python_version": platform.python_version(),
diff --git a/tests/commands/package/test_call.py b/tests/commands/package/test_call.py
index b8bfd9696..d509786e2 100644
--- a/tests/commands/package/test_call.py
+++ b/tests/commands/package/test_call.py
@@ -45,7 +45,7 @@ def test_no_args_package_one_app(package_command, first_app, tmp_path):
     assert first_app.packaging_format == "pkg"
 
     # The dist folder has been created.
-    assert tmp_path / "base_path/dist"
+    assert (tmp_path / "base_path/dist").exists()
 
 
 def test_package_one_explicit_app(package_command, first_app, second_app, tmp_path):
@@ -90,7 +90,7 @@ def test_package_one_explicit_app(package_command, first_app, second_app, tmp_pa
     assert not hasattr(second_app, "packaging_format")
 
     # The dist folder has been created.
-    assert tmp_path / "base_path/dist"
+    assert (tmp_path / "base_path/dist").exists()
 
 
 def test_no_args_package_two_app(package_command, first_app, second_app, tmp_path):
@@ -151,7 +151,7 @@ def test_no_args_package_two_app(package_command, first_app, second_app, tmp_pat
     assert second_app.packaging_format == "pkg"
 
     # The dist folder has been created.
-    assert tmp_path / "base_path/dist"
+    assert (tmp_path / "base_path/dist").exists()
 
 
 def test_identity_arg_package_one_app(package_command, first_app, tmp_path):
@@ -195,7 +195,7 @@ def test_identity_arg_package_one_app(package_command, first_app, tmp_path):
     assert first_app.packaging_format == "pkg"
 
     # The dist folder has been created.
-    assert tmp_path / "base_path/dist"
+    assert (tmp_path / "base_path/dist").exists()
 
 
 def test_adhoc_sign_package_one_app(package_command, first_app, tmp_path):
@@ -239,7 +239,7 @@ def test_adhoc_sign_package_one_app(package_command, first_app, tmp_path):
     assert first_app.packaging_format == "pkg"
 
     # The dist folder has been created.
-    assert tmp_path / "base_path/dist"
+    assert (tmp_path / "base_path/dist").exists()
 
 
 def test_adhoc_sign_args_package_two_app(
@@ -305,7 +305,7 @@ def test_adhoc_sign_args_package_two_app(
     assert second_app.packaging_format == "pkg"
 
     # The dist folder has been created.
-    assert tmp_path / "base_path/dist"
+    assert (tmp_path / "base_path/dist").exists()
 
 
 def test_identity_sign_args_package_two_app(
@@ -369,7 +369,7 @@ def test_identity_sign_args_package_two_app(
     assert second_app.packaging_format == "pkg"
 
     # The dist folder has been created.
-    assert tmp_path / "base_path/dist"
+    assert (tmp_path / "base_path/dist").exists()
 
 
 def test_package_alternate_format(package_command, first_app, tmp_path):
@@ -412,7 +412,7 @@ def test_package_alternate_format(package_command, first_app, tmp_path):
     assert first_app.packaging_format == "box"
 
     # The dist folder has been created.
-    assert tmp_path / "base_path/dist"
+    assert (tmp_path / "base_path/dist").exists()
 
 
 def test_create_before_package(package_command, first_app_config, tmp_path):
@@ -475,7 +475,7 @@ def test_create_before_package(package_command, first_app_config, tmp_path):
     assert first_app_config.packaging_format == "pkg"
 
     # The dist folder has been created.
-    assert tmp_path / "base_path/dist"
+    assert (tmp_path / "base_path/dist").exists()
 
 
 def test_update_package_one_app(package_command, first_app, tmp_path):
@@ -541,7 +541,7 @@ def test_update_package_one_app(package_command, first_app, tmp_path):
     assert first_app.packaging_format == "pkg"
 
     # The dist folder has been created.
-    assert tmp_path / "base_path/dist"
+    assert (tmp_path / "base_path/dist").exists()
 
 
 def test_update_package_two_app(package_command, first_app, second_app, tmp_path):
@@ -655,7 +655,7 @@ def test_update_package_two_app(package_command, first_app, second_app, tmp_path
     assert second_app.packaging_format == "pkg"
 
     # The dist folder has been created.
-    assert tmp_path / "base_path/dist"
+    assert (tmp_path / "base_path/dist").exists()
 
 
 def test_build_before_package(package_command, first_app_unbuilt, tmp_path):
@@ -708,7 +708,7 @@ def test_build_before_package(package_command, first_app_unbuilt, tmp_path):
     assert first_app_unbuilt.packaging_format == "pkg"
 
     # The dist folder has been created.
-    assert tmp_path / "base_path/dist"
+    assert (tmp_path / "base_path/dist").exists()
 
 
 def test_already_packaged(package_command, first_app, tmp_path):
@@ -755,7 +755,7 @@ def test_already_packaged(package_command, first_app, tmp_path):
     assert first_app.packaging_format == "pkg"
 
     # The dist folder still exists
-    assert tmp_path / "base_path/dist"
+    assert (tmp_path / "base_path/dist").exists()
 
     # But the artefact has been deleted.
     # NOTE: This is a testing quirk - because we're mocking the
@@ -916,3 +916,156 @@ def test_package_app_all_flags(package_command, first_app, second_app):
             },
         ),
     ]
+
+
+def test_package_external_not_supported(package_command, first_app, tmp_path):
+    """If the backend doesn't explicitly support external packaging, an error is raised."""
+    # Add a single external app
+    package_command.apps = {
+        "first": first_app,
+    }
+    first_app.external_package_path = "path/to/package"
+    first_app.sources = None
+
+    # Configure no command line arguments
+    options, _ = package_command.parse_options([])
+
+    # Run the build command on a specific app
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=r"Briefcase cannot package external Tester apps in Dummy format.",
+    ):
+        package_command(first_app, **options)
+
+    # The right sequence of things will be done
+    assert package_command.actions == [
+        # Host OS is verified
+        ("verify-host",),
+        # Tools are verified
+        ("verify-tools",),
+        # App config has been finalized
+        ("finalize-app-config", "first"),
+    ]
+
+    # The dist folder has not been created.
+    assert not (tmp_path / "base_path/dist").exists()
+
+
+def test_package_external_app(package_command, first_app, tmp_path):
+    """An external app can be packaged."""
+    # Enable external packaging
+    package_command.supports_external_packaging = True
+
+    # Add a single external app
+    package_command.apps = {
+        "first": first_app,
+    }
+    first_app.external_package_path = "path/to/package"
+    first_app.sources = None
+
+    # Configure no command line arguments
+    options, _ = package_command.parse_options([])
+
+    # Run the build command on a specific app
+    package_command(first_app, **options)
+
+    # The right sequence of things will be done
+    assert package_command.actions == [
+        # Host OS is verified
+        ("verify-host",),
+        # Tools are verified
+        ("verify-tools",),
+        # App config has been finalized
+        ("finalize-app-config", "first"),
+        # App has been created
+        (
+            "create",
+            "first",
+            {
+                "adhoc_sign": False,
+                "identity": None,
+            },
+        ),
+        # App template is verified
+        ("verify-app-template", "first"),
+        # App tools are verified for app
+        ("verify-app-tools", "first"),
+        # Package the first app
+        (
+            "package",
+            "first",
+            {
+                "adhoc_sign": False,
+                "create_state": "first",
+                "identity": None,
+            },
+        ),
+    ]
+
+    # Packaging format has been annotated on the first app, not the second.
+    assert first_app.packaging_format == "pkg"
+
+    # The dist folder has been created.
+    assert (tmp_path / "base_path/dist").exists()
+
+
+def test_create_before_package_external_app(
+    package_command,
+    first_app_config,
+    tmp_path,
+):
+    """If an external app hasn't been created before packaging, it is created first."""
+    # Enable external packaging
+    package_command.supports_external_packaging = True
+
+    # Add a single external app
+    package_command.apps = {
+        "first": first_app_config,
+    }
+    first_app_config.external_package_path = "path/to/package"
+    first_app_config.sources = None
+
+    # Configure no command line arguments
+    options, _ = package_command.parse_options([])
+
+    # Run the build command on a specific app
+    package_command(first_app_config, **options)
+
+    # The right sequence of things will be done
+    assert package_command.actions == [
+        # Host OS is verified
+        ("verify-host",),
+        # Tools are verified
+        ("verify-tools",),
+        # App config has been finalized
+        ("finalize-app-config", "first"),
+        # App has been created
+        (
+            "create",
+            "first",
+            {
+                "adhoc_sign": False,
+                "identity": None,
+            },
+        ),
+        # App template is verified
+        ("verify-app-template", "first"),
+        # App tools are verified for app
+        ("verify-app-tools", "first"),
+        # Package the first app
+        (
+            "package",
+            "first",
+            {
+                "adhoc_sign": False,
+                "create_state": "first",
+                "identity": None,
+            },
+        ),
+    ]
+
+    # Packaging format has been annotated on the first app, not the second.
+    assert first_app_config.packaging_format == "pkg"
+
+    # The dist folder has been created.
+    assert (tmp_path / "base_path/dist").exists()
diff --git a/tests/commands/run/test_call.py b/tests/commands/run/test_call.py
index eb733d5be..0320d9a95 100644
--- a/tests/commands/run/test_call.py
+++ b/tests/commands/run/test_call.py
@@ -939,3 +939,25 @@ def test_test_mode_non_existent(run_command, first_app_config):
             {"build_state": "first", "passthrough": []},
         ),
     ]
+
+
+def test_run_external_app(run_command, first_app):
+    """If the user requests a run an external app, an error is raised."""
+    # Add an apps
+    run_command.apps = {
+        "first": first_app,
+    }
+
+    # Make first_app an external app
+    first_app.sources = None
+    first_app.external_package_path = "path/to/package"
+
+    # Configure no command line options
+    options, _ = run_command.parse_options([])
+
+    # Run the build command
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=r"'first' is declared as an external app",
+    ):
+        run_command(**options)
diff --git a/tests/commands/update/conftest.py b/tests/commands/update/conftest.py
index 014d9e160..a28fb8365 100644
--- a/tests/commands/update/conftest.py
+++ b/tests/commands/update/conftest.py
@@ -83,33 +83,20 @@ def cleanup_app_content(self, app):
 
 
 @pytest.fixture
-def update_command(tmp_path):
-    return DummyUpdateCommand(
-        base_path=tmp_path / "base_path",
-        apps={
-            "first": AppConfig(
-                app_name="first",
-                bundle="com.example",
-                version="0.0.1",
-                description="The first simple app",
-                sources=["src/first"],
-                license={"file": "LICENSE"},
-            ),
-            "second": AppConfig(
-                app_name="second",
-                bundle="com.example",
-                version="0.0.2",
-                description="The second simple app",
-                sources=["src/second"],
-                license={"file": "LICENSE"},
-            ),
-        },
+def first_app_config():
+    """Populate skeleton app content for the first app."""
+    return AppConfig(
+        app_name="first",
+        bundle="com.example",
+        version="0.0.1",
+        description="The first simple app",
+        sources=["src/first"],
+        license={"file": "LICENSE"},
     )
 
 
 @pytest.fixture
-def first_app(tmp_path):
-    """Populate skeleton app content for the first app."""
+def first_app(tmp_path, first_app_config):
     create_file(
         tmp_path
         / "base_path"
@@ -120,10 +107,24 @@ def first_app(tmp_path):
         / "first.bundle",
         "first.bundle",
     )
+    return first_app_config
 
 
 @pytest.fixture
-def second_app(tmp_path):
+def second_app_config():
+    """Populate skeleton app content for the second app."""
+    return AppConfig(
+        app_name="second",
+        bundle="com.example",
+        version="0.0.2",
+        description="The second simple app",
+        sources=["src/second"],
+        license={"file": "LICENSE"},
+    )
+
+
+@pytest.fixture
+def second_app(tmp_path, second_app_config):
     """Populate skeleton app content for the second app."""
     create_file(
         tmp_path
@@ -135,3 +136,15 @@ def second_app(tmp_path):
         / "second.bundle",
         "second.bundle",
     )
+    return second_app_config
+
+
+@pytest.fixture
+def update_command(tmp_path, first_app_config, second_app_config):
+    return DummyUpdateCommand(
+        base_path=tmp_path / "base_path",
+        apps={
+            "first": first_app_config,
+            "second": second_app_config,
+        },
+    )
diff --git a/tests/commands/update/test_call.py b/tests/commands/update/test_call.py
index 0c44a0328..5b05a06c8 100644
--- a/tests/commands/update/test_call.py
+++ b/tests/commands/update/test_call.py
@@ -271,3 +271,25 @@ def test_update_app_all_flags(update_command, first_app, second_app):
         ("support", "first"),
         ("cleanup", "first"),
     ]
+
+
+def test_update_external_app(update_command, first_app):
+    """If the user requests a update an external app, an error is raised."""
+    # Add an apps
+    update_command.apps = {
+        "first": first_app,
+    }
+
+    # Make first_app an external app
+    first_app.sources = None
+    first_app.external_package_path = "path/to/package"
+
+    # Configure no command line options
+    options, _ = update_command.parse_options([])
+
+    # Run the build command
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=r"'first' is declared as an external app",
+    ):
+        update_command(**options)
diff --git a/tests/config/test_AppConfig.py b/tests/config/test_AppConfig.py
index 02929977a..82a3eaf40 100644
--- a/tests/config/test_AppConfig.py
+++ b/tests/config/test_AppConfig.py
@@ -23,6 +23,9 @@ def test_minimal_AppConfig():
     assert config.bundle == "org.beeware"
     assert config.description == "A simple app"
     assert config.requires is None
+    assert config.sources == ["src/myapp", "somewhere/else/interesting", "local_app"]
+    assert config.external_package_path is None
+    assert config.external_package_executable_path is None
 
     # Derived properties have been set.
     assert config.bundle_name == "myapp"
@@ -45,6 +48,49 @@ def test_minimal_AppConfig():
     assert repr(config) == "<org.beeware.myapp v1.2.3 AppConfig>"
 
 
+def test_minimal_external_AppConfig():
+    """A simple config for an external app can be defined."""
+    config = AppConfig(
+        app_name="myapp",
+        version="1.2.3",
+        bundle="org.beeware",
+        description="A simple app",
+        license={"file": "LICENSE"},
+        external_package_path="path/to/package",
+        external_package_executable_path="internal/app.exe",
+    )
+
+    # The basic properties have been set.
+    assert config.app_name == "myapp"
+    assert config.version == "1.2.3"
+    assert config.bundle == "org.beeware"
+    assert config.description == "A simple app"
+    assert config.requires is None
+    assert config.sources is None
+    assert config.external_package_path == "path/to/package"
+    assert config.external_package_executable_path == "internal/app.exe"
+
+    # Derived properties have been set.
+    assert config.bundle_name == "myapp"
+    assert config.bundle_identifier == "org.beeware.myapp"
+    assert config.formal_name == "myapp"
+    assert config.class_name == "myapp"
+    assert config.document_types == {}
+
+    # There is no icon of any kind
+    assert config.icon is None
+
+    # The PYTHONPATH is derived correctly
+    config.test_mode = False
+    assert config.PYTHONPATH() == []
+    # The test mode PYTHONPATH is the same
+    config.test_mode = True
+    assert config.PYTHONPATH() == []
+
+    # The object has a meaningful REPL
+    assert repr(config) == "<org.beeware.myapp v1.2.3 AppConfig>"
+
+
 def test_extra_attrs():
     """A config can contain attributes in addition to those required."""
     config = AppConfig(
diff --git a/tests/platforms/linux/system/test_mixin__finalize_app_config.py b/tests/platforms/linux/system/test_mixin__finalize_app_config.py
index c512ab601..0c089f8af 100644
--- a/tests/platforms/linux/system/test_mixin__finalize_app_config.py
+++ b/tests/platforms/linux/system/test_mixin__finalize_app_config.py
@@ -579,3 +579,34 @@ def test_cascading_distribution_properties(create_command, first_app_config):
         "prop1": "vendor level prop 1",
         "prop2": "debian level prop 2",
     }
+
+
+def test_external_docker(create_command, first_app_config):
+    """An external app cannot be finalized inside Docker."""
+    # Make the app external
+    first_app_config.sources = None
+    first_app_config.external_package_path = "path/to/package"
+
+    # Build the app on a specific target
+    create_command.target_image = "somevendor:surprising"
+    create_command.tools.docker = MagicMock()
+    create_command.target_glibc_version = MagicMock(return_value="2.42")
+
+    # Mock a minimal response from checking /etc/os-release
+    create_command.tools.docker.check_output.return_value = "\n".join(
+        [
+            "ID=somevendor",
+            "VERSION_CODENAME=surprising",
+            "ID_LIKE=debian",
+        ]
+    )
+
+    # Finalize the app config - this will raise an error
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=(
+            r"Briefcase can't currently use Docker to package "
+            r"external apps as Linux system packages."
+        ),
+    ):
+        create_command.finalize_app_config(first_app_config)
diff --git a/tests/platforms/linux/system/test_package__deb.py b/tests/platforms/linux/system/test_package__deb.py
index 78ed7fb8f..66157627a 100644
--- a/tests/platforms/linux/system/test_package__deb.py
+++ b/tests/platforms/linux/system/test_package__deb.py
@@ -56,6 +56,21 @@ def first_app_deb(first_app):
     return first_app
 
 
+@pytest.fixture
+def external_first_app_deb(first_app_deb, tmp_path):
+    # Make the app external
+    first_app_deb.sources = None
+    first_app_deb.external_package_path = tmp_path / "base_path/external/package-deb"
+
+    # Move the generated first app to the external location
+    (tmp_path / "base_path/external").mkdir()
+    shutil.move(
+        tmp_path / "base_path/build/first-app/somevendor/surprising/first-app-0.0.1",
+        tmp_path / "base_path/external/package-deb",
+    )
+    return first_app_deb
+
+
 def test_verify_no_docker(package_command, first_app_deb, monkeypatch):
     """If not using docker, existence of dpkg-deb is verified."""
     # Mock the existence of dpkg-deb
@@ -164,7 +179,7 @@ def test_deb_package(package_command, first_app_deb, tmp_path):
             "dpkg-deb",
             "--build",
             "--root-owner-group",
-            "first-app-0.0.1",
+            bundle_path / "first-app-0.0.1",
         ],
         check=True,
         cwd=bundle_path,
@@ -217,7 +232,7 @@ def test_deb_re_package(package_command, first_app_deb, tmp_path):
             "dpkg-deb",
             "--build",
             "--root-owner-group",
-            "first-app-0.0.1",
+            bundle_path / "first-app-0.0.1",
         ],
         check=True,
         cwd=bundle_path,
@@ -306,7 +321,7 @@ def test_deb_package_extra_requirements(package_command, first_app_deb, tmp_path
             "dpkg-deb",
             "--build",
             "--root-owner-group",
-            "first-app-0.0.1",
+            bundle_path / "first-app-0.0.1",
         ],
         check=True,
         cwd=bundle_path,
@@ -367,7 +382,7 @@ def test_deb_package_failure(package_command, first_app_deb, tmp_path):
             "dpkg-deb",
             "--build",
             "--root-owner-group",
-            "first-app-0.0.1",
+            bundle_path / "first-app-0.0.1",
         ],
         check=True,
         cwd=bundle_path,
@@ -375,3 +390,56 @@ def test_deb_package_failure(package_command, first_app_deb, tmp_path):
 
     # The deb wasn't built, so it wasn't moved.
     package_command.tools.shutil.move.assert_not_called()
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="Can't build debs on Windows")
+def test_external_deb_package_docker(package_command, external_first_app_deb, tmp_path):
+    """An external app cannot be packaged as a deb using Docker."""
+    package_command
+    bundle_path = tmp_path / "base_path/build/first-app/somevendor/surprising"
+    package_path = tmp_path / "base_path/external/package-deb"
+
+    # Package the app
+    package_command.package_app(external_first_app_deb)
+
+    # The control file is written
+    assert (package_path / "DEBIAN/control").exists()
+    with (package_path / "DEBIAN/control").open(encoding="utf-8") as f:
+        assert (
+            f.read()
+            == "\n".join(
+                [
+                    "Package: first-app",
+                    "Version: 0.0.1",
+                    "Architecture: wonky",
+                    "Maintainer: Megacorp <maintainer@example.com>",
+                    "Homepage: https://example.com/first-app",
+                    "Description: The first simple app \\ demonstration",
+                    " Long description",
+                    " for the app",
+                    "Depends: libc6 (>=2.99), libpython3.10",
+                    "Section: utils",
+                    "Priority: optional",
+                ]
+            )
+            + "\n"
+        )
+
+    package_command.tools.app_tools[
+        external_first_app_deb
+    ].app_context.run.assert_called_once_with(
+        [
+            "dpkg-deb",
+            "--build",
+            "--root-owner-group",
+            package_path,
+        ],
+        check=True,
+        cwd=bundle_path,
+    )
+
+    # The deb was moved into the final location
+    package_command.tools.shutil.move.assert_called_once_with(
+        tmp_path / "base_path/external/package-deb.deb",
+        tmp_path / "base_path/dist/first-app_0.0.1-1~somevendor-surprising_wonky.deb",
+    )
diff --git a/tests/platforms/linux/system/test_package__pkg.py b/tests/platforms/linux/system/test_package__pkg.py
index cb37a4e52..2a5187faa 100644
--- a/tests/platforms/linux/system/test_package__pkg.py
+++ b/tests/platforms/linux/system/test_package__pkg.py
@@ -76,6 +76,21 @@ def first_app_pkg(first_app, tmp_path):
     return first_app
 
 
+@pytest.fixture
+def external_first_app_pkg(first_app_pkg, tmp_path):
+    # Make the app external
+    first_app_pkg.sources = None
+    first_app_pkg.external_package_path = tmp_path / "base_path/external/package-pkg"
+
+    # Move the generated first app to the external location
+    (tmp_path / "base_path/external").mkdir()
+    shutil.move(
+        tmp_path / "base_path/build/first-app/somevendor/surprising/first-app-0.0.1",
+        tmp_path / "base_path/external/package-pkg",
+    )
+    return first_app_pkg
+
+
 def test_verify_no_docker(package_command, first_app_pkg, monkeypatch):
     """If not using docker, existence of makepkg is verified."""
     # Mock the existence of makepkg
@@ -497,3 +512,94 @@ def test_no_changelog(package_command, first_app_pkg, tmp_path):
 
     # The pkg wasn't built, so it wasn't moved.
     package_command.tools.shutil.move.assert_not_called()
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="Can't build PKGs on Windows")
+def test_external_pkg_package(
+    package_command,
+    external_first_app_pkg,
+    tmp_path,
+):
+    """An external app can be packaged as a PKD."""
+    bundle_path = tmp_path / "base_path/build/first-app/somevendor/surprising"
+
+    # Package the app
+    package_command.package_app(external_first_app_pkg)
+
+    # The CHANGELOG file is copied
+    assert (bundle_path / "pkgbuild/CHANGELOG").exists()
+
+    # The PKGBUILD file is written
+    assert (bundle_path / "pkgbuild/PKGBUILD").exists()
+    with (bundle_path / "pkgbuild/PKGBUILD").open(encoding="utf-8") as f:
+        assert f.read() == "\n".join(
+            [
+                "# Maintainer: Megacorp <maintainer@example.com>",
+                'export PACKAGER="Megacorp <maintainer@example.com>"',
+                "pkgname=first-app",
+                "pkgver=0.0.1",
+                "pkgrel=1",
+                'pkgdesc="Description for the app"',
+                "arch=('wonky')",
+                'url="https://example.com/first-app"',
+                "license=('Unknown')",
+                "depends=('glibc>=2.99' 'python3')",
+                "changelog=CHANGELOG",
+                'source=("$pkgname-$pkgver.tar.gz")',
+                "md5sums=('SKIP')",
+                "options=('!strip')",
+                "package() {",
+                '    cp -r "$srcdir/$pkgname-$pkgver/usr/" "$pkgdir"/usr/',
+                "}",
+            ]
+        )
+
+    # A source tarball was created with the right content
+    # In particular, the top level path is `first-app-0.0.1`, not `package-pkg`
+    archive_file = bundle_path / "pkgbuild/first-app-0.0.1.tar.gz"
+    assert archive_file.exists()
+    with tarfile.open(archive_file, "r:gz") as archive:
+        assert sorted(archive.getnames()) == [
+            "first-app-0.0.1",
+            "first-app-0.0.1/usr",
+            "first-app-0.0.1/usr/bin",
+            "first-app-0.0.1/usr/bin/first-app",
+            "first-app-0.0.1/usr/lib",
+            "first-app-0.0.1/usr/lib/first-app",
+            "first-app-0.0.1/usr/lib/first-app/app",
+            "first-app-0.0.1/usr/lib/first-app/app/support.so",
+            "first-app-0.0.1/usr/lib/first-app/app/support_same_perms.so",
+            "first-app-0.0.1/usr/lib/first-app/app_packages",
+            "first-app-0.0.1/usr/lib/first-app/app_packages/firstlib",
+            "first-app-0.0.1/usr/lib/first-app/app_packages/firstlib/first.so",
+            "first-app-0.0.1/usr/lib/first-app/app_packages/firstlib/first.so.1.0",
+            "first-app-0.0.1/usr/lib/first-app/app_packages/secondlib",
+            "first-app-0.0.1/usr/lib/first-app/app_packages/secondlib/second_a.so",
+            "first-app-0.0.1/usr/lib/first-app/app_packages/secondlib/second_b.so",
+            "first-app-0.0.1/usr/share",
+            "first-app-0.0.1/usr/share/doc",
+            "first-app-0.0.1/usr/share/doc/first-app",
+            "first-app-0.0.1/usr/share/doc/first-app/UserManual",
+            "first-app-0.0.1/usr/share/doc/first-app/license",
+            "first-app-0.0.1/usr/share/man",
+            "first-app-0.0.1/usr/share/man/man1",
+            "first-app-0.0.1/usr/share/man/man1/first-app.1.gz",
+        ]
+
+    # makepkg was invoked
+    package_command.tools.app_tools[
+        external_first_app_pkg
+    ].app_context.run.assert_called_once_with(
+        [
+            "makepkg",
+        ],
+        check=True,
+        cwd=(bundle_path / "pkgbuild"),
+        env={"PKGEXT": ".pkg.tar.zst"},
+    )
+
+    # The pkg was moved into the final location
+    package_command.tools.shutil.move.assert_called_once_with(
+        bundle_path / "pkgbuild/first-app-0.0.1-1-wonky.pkg.tar.zst",
+        tmp_path / "base_path/dist/first-app-0.0.1-1-wonky.pkg.tar.zst",
+    )
diff --git a/tests/platforms/linux/system/test_package__rpm.py b/tests/platforms/linux/system/test_package__rpm.py
index 2ce677756..2ee57c9ed 100644
--- a/tests/platforms/linux/system/test_package__rpm.py
+++ b/tests/platforms/linux/system/test_package__rpm.py
@@ -76,6 +76,21 @@ def first_app_rpm(first_app, tmp_path):
     return first_app
 
 
+@pytest.fixture
+def external_first_app_rpm(first_app_rpm, tmp_path):
+    # Make the app external
+    first_app_rpm.sources = None
+    first_app_rpm.external_package_path = tmp_path / "base_path/external/package-rpm"
+
+    # Move the generated first app to the external location
+    (tmp_path / "base_path/external").mkdir()
+    shutil.move(
+        tmp_path / "base_path/build/first-app/somevendor/surprising/first-app-0.0.1",
+        tmp_path / "base_path/external/package-rpm",
+    )
+    return first_app_rpm
+
+
 def test_verify_no_docker(package_command, first_app_rpm, monkeypatch):
     """If not using docker, existence of rpmbuild is verified."""
     # Mock the existence of rpmbuild
@@ -656,3 +671,140 @@ def test_no_changelog(package_command, first_app_rpm, tmp_path):
 
     # The deb wasn't built, so it wasn't moved.
     package_command.tools.shutil.move.assert_not_called()
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="Can't build RPMs on Windows")
+def test_external_rpm_package(package_command, external_first_app_rpm, tmp_path):
+    """An external app can be published as an RPM."""
+    bundle_path = tmp_path / "base_path/build/first-app/somevendor/surprising"
+
+    # Package the app
+    package_command.package_app(external_first_app_rpm)
+
+    # rpmbuild layout has been generated
+    assert (bundle_path / "rpmbuild/BUILD").exists()
+    assert (bundle_path / "rpmbuild/BUILDROOT").exists()
+    assert (bundle_path / "rpmbuild/RPMS").exists()
+    assert (bundle_path / "rpmbuild/SOURCES").exists()
+    assert (bundle_path / "rpmbuild/SRPMS").exists()
+    assert (bundle_path / "rpmbuild/SPECS").exists()
+
+    # The spec file is written
+    assert (bundle_path / "rpmbuild/SPECS/first-app.spec").exists()
+    with (bundle_path / "rpmbuild/SPECS/first-app.spec").open(encoding="utf-8") as f:
+        assert f.read() == "\n".join(
+            [
+                "%global __brp_mangle_shebangs %{nil}",
+                "%global __brp_strip %{nil}",
+                "%global __brp_strip_static_archive %{nil}",
+                "%global __brp_strip_comment_note %{nil}",
+                "%global __brp_check_rpaths %{nil}",
+                "%global __requires_exclude_from ^%{_libdir}/first-app/.*$",
+                "%global __provides_exclude_from ^%{_libdir}/first-app/.*$",
+                "%global _enable_debug_package 0",
+                "%global debug_package %{nil}",
+                "",
+                "Name:           first-app",
+                "Version:        0.0.1",
+                "Release:        1%{?dist}",
+                "Summary:        The first simple app \\ demonstration",
+                "",
+                "License:        Unknown",
+                "URL:            https://example.com/first-app",
+                "Source0:        %{name}-%{version}.tar.gz",
+                "",
+                "Requires:       python3",
+                "",
+                "ExclusiveArch:  wonky",
+                "",
+                "%description",
+                "Long description",
+                "for the app",
+                "",
+                "%prep",
+                "%autosetup",
+                "",
+                "%build",
+                "",
+                "%install",
+                "cp -r usr %{buildroot}/usr",
+                "",
+                "%files",
+                '"/usr/bin/first-app"',
+                '%dir "/usr/lib/first-app"',
+                '%dir "/usr/lib/first-app/app"',
+                '"/usr/lib/first-app/app/support.so"',
+                '"/usr/lib/first-app/app/support_same_perms.so"',
+                '%dir "/usr/lib/first-app/app_packages"',
+                '%dir "/usr/lib/first-app/app_packages/firstlib"',
+                '"/usr/lib/first-app/app_packages/firstlib/first.so"',
+                '"/usr/lib/first-app/app_packages/firstlib/first.so.1.0"',
+                '%dir "/usr/lib/first-app/app_packages/secondlib"',
+                '"/usr/lib/first-app/app_packages/secondlib/second_a.so"',
+                '"/usr/lib/first-app/app_packages/secondlib/second_b.so"',
+                '%dir "/usr/share/doc/first-app"',
+                '"/usr/share/doc/first-app/UserManual"',
+                '"/usr/share/doc/first-app/license"',
+                '"/usr/share/man/man1/first-app.1.gz"',
+                "",
+                "%changelog",
+                "First App Changelog",
+            ]
+        )
+
+    # A source tarball was created with the right content
+    # In particular, the top level path is `first-app-0.0.1`, not `package-rpm`
+    archive_file = bundle_path / "rpmbuild/SOURCES/first-app-0.0.1.tar.gz"
+    assert archive_file.exists()
+    with tarfile.open(archive_file, "r:gz") as archive:
+        assert sorted(archive.getnames()) == [
+            "first-app-0.0.1",
+            "first-app-0.0.1/usr",
+            "first-app-0.0.1/usr/bin",
+            "first-app-0.0.1/usr/bin/first-app",
+            "first-app-0.0.1/usr/lib",
+            "first-app-0.0.1/usr/lib/first-app",
+            "first-app-0.0.1/usr/lib/first-app/app",
+            "first-app-0.0.1/usr/lib/first-app/app/support.so",
+            "first-app-0.0.1/usr/lib/first-app/app/support_same_perms.so",
+            "first-app-0.0.1/usr/lib/first-app/app_packages",
+            "first-app-0.0.1/usr/lib/first-app/app_packages/firstlib",
+            "first-app-0.0.1/usr/lib/first-app/app_packages/firstlib/first.so",
+            "first-app-0.0.1/usr/lib/first-app/app_packages/firstlib/first.so.1.0",
+            "first-app-0.0.1/usr/lib/first-app/app_packages/secondlib",
+            "first-app-0.0.1/usr/lib/first-app/app_packages/secondlib/second_a.so",
+            "first-app-0.0.1/usr/lib/first-app/app_packages/secondlib/second_b.so",
+            "first-app-0.0.1/usr/share",
+            "first-app-0.0.1/usr/share/doc",
+            "first-app-0.0.1/usr/share/doc/first-app",
+            "first-app-0.0.1/usr/share/doc/first-app/UserManual",
+            "first-app-0.0.1/usr/share/doc/first-app/license",
+            "first-app-0.0.1/usr/share/man",
+            "first-app-0.0.1/usr/share/man/man1",
+            "first-app-0.0.1/usr/share/man/man1/first-app.1.gz",
+        ]
+
+    # rpmbuild was invoked
+    package_command.tools.app_tools[
+        external_first_app_rpm
+    ].app_context.run.assert_called_once_with(
+        [
+            "rpmbuild",
+            "-bb",
+            "--define",
+            f"_topdir {bundle_path / 'rpmbuild'}",
+            "./rpmbuild/SPECS/first-app.spec",
+        ],
+        check=True,
+        cwd=bundle_path,
+    )
+
+    # The rpm was moved into the final location
+    package_command.tools.shutil.move.assert_called_once_with(
+        bundle_path
+        / "rpmbuild"
+        / "RPMS"
+        / "wonky"
+        / "first-app-0.0.1-1.fcXX.wonky.rpm",
+        tmp_path / "base_path/dist/first-app-0.0.1-1.fcXX.wonky.rpm",
+    )
diff --git a/tests/platforms/macOS/app/package/conftest.py b/tests/platforms/macOS/app/package/conftest.py
index 37ad56a7f..5cea7cdca 100644
--- a/tests/platforms/macOS/app/package/conftest.py
+++ b/tests/platforms/macOS/app/package/conftest.py
@@ -1,3 +1,4 @@
+import shutil
 from unittest import mock
 
 import pytest
@@ -15,6 +16,7 @@ def package_command(tmp_path):
         data_path=tmp_path / "briefcase",
     )
 
+    command._command_factory = mock.MagicMock()
     command.select_identity = mock.MagicMock()
     command.sign_app = mock.MagicMock()
     command.sign_file = mock.MagicMock()
@@ -24,3 +26,24 @@ def package_command(tmp_path):
     command.verify_not_on_icloud = mock.MagicMock()
 
     return command
+
+
+@pytest.fixture
+def external_first_app(first_app_with_binaries, first_app_config, tmp_path):
+    # Convert the first_app_config into an external app
+    first_app_config.sources = None
+    first_app_config.external_package_path = (
+        tmp_path / "base_path/external/First App.app"
+    )
+
+    # Move the binaries from the compiled first app to the external location
+    first_app_config.external_package_path.parent.mkdir(parents=True)
+    shutil.move(
+        tmp_path / "base_path/build/first-app/macos/app/First App.app",
+        tmp_path / "base_path/external/First App.app",
+    )
+
+    # And purge the original package path, so we have a "clean" template directory
+    shutil.rmtree(tmp_path / "base_path/build/first-app/macos/app")
+
+    return first_app_config
diff --git a/tests/platforms/macOS/app/package/test_package_dmg.py b/tests/platforms/macOS/app/package/test_package_dmg.py
index e2debcb6e..601a4f428 100644
--- a/tests/platforms/macOS/app/package/test_package_dmg.py
+++ b/tests/platforms/macOS/app/package/test_package_dmg.py
@@ -294,3 +294,66 @@ def test_dmg_with_missing_installer_background(
         "Can't find pretty_background.png to use as DMG background\n"
         in capsys.readouterr().out
     )
+
+
+def test_dmg_external_app(
+    package_command,
+    external_first_app,
+    sekrit_identity,
+    tmp_path,
+    capsys,
+):
+    """An external macOS App can be packaged as a signed, notarized DMG by default."""
+    # Select a codesigning identity
+    package_command.select_identity.return_value = sekrit_identity
+
+    # 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(
+        external_first_app,
+        update=False,
+        packaging_format="dmg",
+    )
+
+    # A request has been made to sign the app
+    package_command.sign_app.assert_called_once_with(
+        app=external_first_app,
+        identity=sekrit_identity,
+    )
+
+    # The DMG has been built as expected
+    package_command.dmgbuild.build_dmg.assert_called_once_with(
+        filename=os.fsdecode(tmp_path / "base_path/dist/First App-0.0.1.dmg"),
+        volume_name="First App 0.0.1",
+        settings={
+            "files": [
+                os.fsdecode(tmp_path / "base_path" / "external" / "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 / "base_path/dist/First App-0.0.1.dmg",
+        identity=sekrit_identity,
+    )
+
+    # A request was made to notarize the DMG
+    package_command.notarize.assert_called_once_with(
+        external_first_app,
+        identity=sekrit_identity,
+    )
+
+    # 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
diff --git a/tests/platforms/macOS/app/package/test_package_pkg.py b/tests/platforms/macOS/app/package/test_package_pkg.py
index 32f4da502..b8847c577 100644
--- a/tests/platforms/macOS/app/package/test_package_pkg.py
+++ b/tests/platforms/macOS/app/package/test_package_pkg.py
@@ -549,3 +549,125 @@ def test_package_pkg_previously_built(
             check=True,
         ),
     ]
+
+
+def test_external_app(
+    package_command,
+    external_first_app,
+    license_file,
+    sekrit_identity,
+    sekrit_installer_identity,
+    tmp_path,
+):
+    """A macOS GUI app can be packaged as a .pkg installer."""
+    external_first_app.packaging_format = "pkg"
+
+    # Select a codesigning identity
+    package_command.select_identity.side_effect = [
+        sekrit_identity,
+        sekrit_installer_identity,
+    ]
+
+    # Mock the notarization process
+    package_command.notarize = mock.Mock()
+
+    bundle_path = tmp_path / "base_path/build/first-app/macos/app"
+
+    # Create a pre-existing app bundle.
+    create_file(
+        bundle_path / "installer/root/First App.app/original",
+        "Original app",
+    )
+
+    # Create a pre-existing package bundle.
+    create_file(
+        bundle_path / "installer/packages/first-app.pkg",
+        "Original package",
+    )
+
+    # Create a pre-existing LICENSE
+    create_file(
+        bundle_path / "installer/resources/LICENSE",
+        "Original License",
+    )
+
+    # Re-package the app
+    package_command.package_app(external_first_app)
+
+    # Two signing identities were selected; the second is an installer identity
+    assert package_command.select_identity.mock_calls == [
+        mock.call(identity=None),
+        mock.call(identity=None, app_identity=sekrit_identity),
+    ]
+
+    # The app has been signed
+    package_command.sign_app.assert_called_once_with(
+        app=external_first_app,
+        identity=sekrit_identity,
+    )
+
+    # 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"
+    ) == "You can take license with this."
+
+    # The component list has been updated.
+    with (bundle_path / "installer/components.plist").open("rb") as f:
+        components = plistlib.load(f)
+
+        assert components == [
+            {
+                "BundleHasStrictIdentifier": True,
+                "BundleIsRelocatable": False,
+                "BundleIsVersionChecked": True,
+                "BundleOverwriteAction": "upgrade",
+                "RootRelativeBundlePath": "First App.app",
+            }
+        ]
+
+    assert package_command.tools.subprocess.run.mock_calls == [
+        mock.call(
+            [
+                "pkgbuild",
+                "--root",
+                bundle_path / "installer/root",
+                "--component-plist",
+                bundle_path / "installer/components.plist",
+                "--install-location",
+                "/Applications",
+                bundle_path / "installer/packages/first-app.pkg",
+            ],
+            check=True,
+        ),
+        mock.call(
+            [
+                "productbuild",
+                "--distribution",
+                bundle_path / "installer/Distribution.xml",
+                "--package-path",
+                bundle_path / "installer/packages",
+                "--resources",
+                bundle_path / "installer/resources",
+                "--sign",
+                "CAFEFACE",
+                tmp_path / "base_path/dist/First App-0.0.1.pkg",
+            ],
+            check=True,
+        ),
+    ]
+
+    # Notarization was performed with the installer identity
+    package_command.notarize.assert_called_once_with(
+        external_first_app,
+        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 8331a6b02..22efc0b78 100644
--- a/tests/platforms/macOS/app/package/test_package_zip.py
+++ b/tests/platforms/macOS/app/package/test_package_zip.py
@@ -88,3 +88,46 @@ def test_zip_no_notarization(
         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_external_app(
+    package_command,
+    external_first_app,
+    sekrit_identity,
+):
+    """An external macOS App can be packaged as a zip."""
+    # Mock the creation of the ditto archive
+    package_command.ditto_archive = MagicMock()
+
+    # Select zip packaging
+    external_first_app.packaging_format = "zip"
+
+    # Select a code signing identity
+    package_command.select_identity.return_value = sekrit_identity
+
+    # Package the app in zip (not DMG) format
+    package_command.package_app(external_first_app)
+
+    # A request has been made to sign the app
+    package_command.sign_app.assert_called_once_with(
+        app=external_first_app,
+        identity=sekrit_identity,
+    )
+
+    # A request has been made to notarize the app
+    package_command.notarize.assert_called_once_with(
+        external_first_app,
+        identity=sekrit_identity,
+    )
+
+    # 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
+
+    # 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.
diff --git a/tests/platforms/macOS/app/test_create.py b/tests/platforms/macOS/app/test_create.py
index 99c1ea510..1998aaa5a 100644
--- a/tests/platforms/macOS/app/test_create.py
+++ b/tests/platforms/macOS/app/test_create.py
@@ -312,6 +312,29 @@ def test_generate_app_template(create_command, first_app, tmp_path):
     create_command.verify_not_on_icloud.assert_called_once_with(first_app, cleanup=True)
 
 
+def test_generate_app_template_formal_name_mismatch(
+    create_command, first_app, tmp_path
+):
+    """If the app's formal name doesn't match the external package path, an error is
+    raised."""
+    first_app.external_package_path = "output/Unexpected Name.app"
+
+    with pytest.raises(
+        BriefcaseCommandError,
+        match=(
+            r"The app bundle referenced by external_package_path \(Unexpected Name.app\)\n"
+            r"does not match the formal name of the app \('First App'\)."
+        ),
+    ):
+        create_command.generate_app_template(first_app)
+
+    # The template was not generated.
+    create_command.generate_template.assert_not_called()
+
+    # iCloud was not verified either.
+    create_command.verify_not_on_icloud.assert_not_called()
+
+
 def test_install_app_resources(create_command, first_app_templated, tmp_path):
     """The app bundle's modification time is updated when app resources are
     installed."""
diff --git a/tests/platforms/windows/app/conftest.py b/tests/platforms/windows/app/conftest.py
index 6cb6517e8..27f79c489 100644
--- a/tests/platforms/windows/app/conftest.py
+++ b/tests/platforms/windows/app/conftest.py
@@ -12,3 +12,18 @@ def first_app_templated(first_app_config, tmp_path):
     create_file(app_path / "Stub.exe", "Stub binary")
 
     return first_app_config
+
+
+@pytest.fixture
+def external_first_app(first_app_config, tmp_path):
+    first_app_config.sources = None
+    first_app_config.external_package_path = tmp_path / "base_path/external/src"
+    first_app_config.external_package_executable_path = "internal/app.exe"
+
+    # Create the binary
+    create_file(
+        tmp_path / "base_path/external/src/internal/app.exe",
+        "external binary",
+    )
+
+    return first_app_config
diff --git a/tests/platforms/windows/app/test_create.py b/tests/platforms/windows/app/test_create.py
index 1dc15ee51..750e24aa5 100644
--- a/tests/platforms/windows/app/test_create.py
+++ b/tests/platforms/windows/app/test_create.py
@@ -127,6 +127,7 @@ def test_default_install_scope(create_command, first_app_config, tmp_path):
     context = create_command.output_format_template_context(first_app_config)
 
     assert context == {
+        "binary_path": "First App.exe",
         "guid": "d666a4f1-c7b7-52cc-888a-3a35a7cc97e5",
         "version_triple": "0.0.1",
         "install_scope": None,
@@ -140,6 +141,7 @@ def test_per_machine_install_scope(create_command, first_app_config, tmp_path):
     context = create_command.output_format_template_context(first_app_config)
 
     assert context == {
+        "binary_path": "First App.exe",
         "guid": "d666a4f1-c7b7-52cc-888a-3a35a7cc97e5",
         "version_triple": "0.0.1",
         "install_scope": "perMachine",
@@ -153,6 +155,7 @@ def test_per_user_install_scope(create_command, first_app_config, tmp_path):
     context = create_command.output_format_template_context(first_app_config)
 
     assert context == {
+        "binary_path": "First App.exe",
         "guid": "d666a4f1-c7b7-52cc-888a-3a35a7cc97e5",
         "version_triple": "0.0.1",
         "install_scope": "perUser",
diff --git a/tests/platforms/windows/app/test_package.py b/tests/platforms/windows/app/test_package.py
index 2a1001fa8..e77639740 100644
--- a/tests/platforms/windows/app/test_package.py
+++ b/tests/platforms/windows/app/test_package.py
@@ -1,4 +1,3 @@
-from pathlib import Path
 from subprocess import CalledProcessError
 from unittest import mock
 from zipfile import ZipFile
@@ -207,13 +206,14 @@ def test_package_msi(package_command, first_app_config, kwargs, tmp_path):
 
     package_command.package_app(first_app_config, **kwargs)
 
+    package_path = tmp_path / "base_path/build/first-app/windows/app/src"
     assert package_command.tools.subprocess.run.mock_calls == [
         # Collect manifest
         mock.call(
             [
                 tmp_path / "wix/bin/heat.exe",
                 "dir",
-                Path("src"),
+                package_path,
                 "-nologo",
                 "-gg",
                 "-sfrag",
@@ -243,7 +243,7 @@ def test_package_msi(package_command, first_app_config, kwargs, tmp_path):
                 "WixUIExtension",
                 "-arch",
                 "x64",
-                "-dSourceDir=src",
+                f"-dSourceDir={package_path}",
                 "first-app.wxs",
                 "first-app-manifest.wxs",
             ],
@@ -345,6 +345,7 @@ def test_package_msi_with_codesigning(
         timestamp_digest="sha56",
     )
 
+    package_path = tmp_path / "base_path/build/first-app/windows/app/src"
     assert package_command.tools.subprocess.run.mock_calls == [
         # Codesign app exe
         mock.call(
@@ -389,7 +390,7 @@ def test_package_msi_with_codesigning(
             [
                 tmp_path / "wix/bin/heat.exe",
                 "dir",
-                Path("src"),
+                package_path,
                 "-nologo",
                 "-gg",
                 "-sfrag",
@@ -419,7 +420,7 @@ def test_package_msi_with_codesigning(
                 "WixUIExtension",
                 "-arch",
                 "x64",
-                "-dSourceDir=src",
+                f"-dSourceDir={package_path}",
                 "first-app.wxs",
                 "first-app-manifest.wxs",
             ],
@@ -608,13 +609,14 @@ def test_package_msi_failed_manifest(package_command, first_app_config, tmp_path
     ):
         package_command.package_app(first_app_config)
 
+    package_path = tmp_path / "base_path/build/first-app/windows/app/src"
     assert package_command.tools.subprocess.run.mock_calls == [
         # Collect manifest
         mock.call(
             [
                 tmp_path / "wix/bin/heat.exe",
                 "dir",
-                Path("src"),
+                package_path,
                 "-nologo",
                 "-gg",
                 "-sfrag",
@@ -650,13 +652,14 @@ def test_package_msi_failed_compile(package_command, first_app_config, tmp_path)
     ):
         package_command.package_app(first_app_config)
 
+    package_path = tmp_path / "base_path/build/first-app/windows/app/src"
     assert package_command.tools.subprocess.run.mock_calls == [
         # Collect manifest
         mock.call(
             [
                 tmp_path / "wix/bin/heat.exe",
                 "dir",
-                Path("src"),
+                package_path,
                 "-nologo",
                 "-gg",
                 "-sfrag",
@@ -686,7 +689,7 @@ def test_package_msi_failed_compile(package_command, first_app_config, tmp_path)
                 "WixUIExtension",
                 "-arch",
                 "x64",
-                "-dSourceDir=src",
+                f"-dSourceDir={package_path}",
                 "first-app.wxs",
                 "first-app-manifest.wxs",
             ],
@@ -711,13 +714,14 @@ def test_package_msi_failed_link(package_command, first_app_config, tmp_path):
     ):
         package_command.package_app(first_app_config)
 
+    package_path = tmp_path / "base_path/build/first-app/windows/app/src"
     assert package_command.tools.subprocess.run.mock_calls == [
         # Collect manifest
         mock.call(
             [
                 tmp_path / "wix/bin/heat.exe",
                 "dir",
-                Path("src"),
+                package_path,
                 "-nologo",
                 "-gg",
                 "-sfrag",
@@ -747,7 +751,7 @@ def test_package_msi_failed_link(package_command, first_app_config, tmp_path):
                 "WixUIExtension",
                 "-arch",
                 "x64",
-                "-dSourceDir=src",
+                f"-dSourceDir={package_path}",
                 "first-app.wxs",
                 "first-app-manifest.wxs",
             ],
@@ -797,6 +801,7 @@ def test_package_msi_failed_signing_msi(package_command, first_app_config, tmp_p
             timestamp_digest="sha56",
         )
 
+    package_path = tmp_path / "base_path/build/first-app/windows/app/src"
     assert package_command.tools.subprocess.run.mock_calls == [
         # Codesign app exe
         mock.call(
@@ -831,7 +836,7 @@ def test_package_msi_failed_signing_msi(package_command, first_app_config, tmp_p
             [
                 tmp_path / "wix/bin/heat.exe",
                 "dir",
-                Path("src"),
+                package_path,
                 "-nologo",
                 "-gg",
                 "-sfrag",
@@ -861,7 +866,7 @@ def test_package_msi_failed_signing_msi(package_command, first_app_config, tmp_p
                 "WixUIExtension",
                 "-arch",
                 "x64",
-                "-dSourceDir=src",
+                f"-dSourceDir={package_path}",
                 "first-app.wxs",
                 "first-app-manifest.wxs",
             ],
@@ -916,3 +921,195 @@ def test_package_msi_failed_signing_msi(package_command, first_app_config, tmp_p
             check=True,
         ),
     ]
+
+
+def test_external_package_msi(
+    package_command,
+    external_first_app,
+    tmp_path,
+):
+    """A Windows app can be packaged as an MSI and code signed."""
+
+    package_command.package_app(
+        external_first_app,
+        identity="80ee4c3321122916f5637522451993c2a0a4a56a",
+        file_digest="sha42",
+        use_local_machine=False,
+        cert_store="mystore",
+        timestamp_url="http://freetimestamps.com",
+        timestamp_digest="sha56",
+    )
+
+    package_path = tmp_path / "base_path/external/src"
+    assert package_command.tools.subprocess.run.mock_calls == [
+        # Codesign app exe
+        mock.call(
+            [
+                tmp_path
+                / "windows_sdk"
+                / "bin"
+                / "81.2.1.0"
+                / "groovy"
+                / "signtool.exe",
+                "sign",
+                "-s",
+                "mystore",
+                "-sha1",
+                "80ee4c3321122916f5637522451993c2a0a4a56a",
+                "-fd",
+                "sha42",
+                "-d",
+                "The first simple app \\ demonstration",
+                "-du",
+                "https://example.com/first-app",
+                "-tr",
+                "http://freetimestamps.com",
+                "-td",
+                "sha56",
+            ]
+            + [tmp_path / "base_path/external/src/internal/app.exe"],
+            check=True,
+        ),
+        # Collect manifest
+        mock.call(
+            [
+                tmp_path / "wix/bin/heat.exe",
+                "dir",
+                package_path,
+                "-nologo",
+                "-gg",
+                "-sfrag",
+                "-sreg",
+                "-srd",
+                "-scom",
+                "-dr",
+                "first_app_ROOTDIR",
+                "-cg",
+                "first_app_COMPONENTS",
+                "-var",
+                "var.SourceDir",
+                "-out",
+                "first-app-manifest.wxs",
+            ],
+            check=True,
+            cwd=tmp_path / "base_path/build/first-app/windows/app",
+        ),
+        # Compile MSI
+        mock.call(
+            [
+                tmp_path / "wix/bin/candle.exe",
+                "-nologo",
+                "-ext",
+                "WixUtilExtension",
+                "-ext",
+                "WixUIExtension",
+                "-arch",
+                "x64",
+                f"-dSourceDir={package_path}",
+                "first-app.wxs",
+                "first-app-manifest.wxs",
+            ],
+            check=True,
+            cwd=tmp_path / "base_path/build/first-app/windows/app",
+        ),
+        # Link MSI
+        mock.call(
+            [
+                tmp_path / "wix/bin/light.exe",
+                "-nologo",
+                "-ext",
+                "WixUtilExtension",
+                "-ext",
+                "WixUIExtension",
+                "-loc",
+                "unicode.wxl",
+                "-o",
+                tmp_path / "base_path/dist/First App-0.0.1.msi",
+                "first-app.wixobj",
+                "first-app-manifest.wixobj",
+            ],
+            check=True,
+            cwd=tmp_path / "base_path/build/first-app/windows/app",
+        ),
+        # Codesign app MSI
+        mock.call(
+            [
+                tmp_path
+                / "windows_sdk"
+                / "bin"
+                / "81.2.1.0"
+                / "groovy"
+                / "signtool.exe",
+                "sign",
+                "-s",
+                "mystore",
+                "-sha1",
+                "80ee4c3321122916f5637522451993c2a0a4a56a",
+                "-fd",
+                "sha42",
+                "-d",
+                "The first simple app \\ demonstration",
+                "-du",
+                "https://example.com/first-app",
+                "-tr",
+                "http://freetimestamps.com",
+                "-td",
+                "sha56",
+            ]
+            + [tmp_path / "base_path/dist/First App-0.0.1.msi"],
+            check=True,
+        ),
+    ]
+
+
+def test_external_package_zip(
+    package_command_with_files,
+    external_first_app,
+    tmp_path,
+):
+    """In a ZIP package, only the binary will be code signed."""
+
+    external_first_app.packaging_format = "zip"
+
+    package_command_with_files.package_app(
+        external_first_app,
+        identity="80ee4c3321122916f5637522451993c2a0a4a56a",
+        file_digest="sha42",
+        use_local_machine=False,
+        cert_store="mystore",
+        timestamp_url="http://freetimestamps.com",
+        timestamp_digest="sha56",
+    )
+
+    assert package_command_with_files.tools.subprocess.run.mock_calls == [
+        # Codesign app exe
+        mock.call(
+            [
+                tmp_path
+                / "windows_sdk"
+                / "bin"
+                / "81.2.1.0"
+                / "groovy"
+                / "signtool.exe",
+                "sign",
+                "-s",
+                "mystore",
+                "-sha1",
+                "80ee4c3321122916f5637522451993c2a0a4a56a",
+                "-fd",
+                "sha42",
+                "-d",
+                "The first simple app \\ demonstration",
+                "-du",
+                "https://example.com/first-app",
+                "-tr",
+                "http://freetimestamps.com",
+                "-td",
+                "sha56",
+            ]
+            + [
+                tmp_path / "base_path/external/src/internal/app.exe",
+            ],
+            check=True,
+        ),
+    ]
diff --git a/tests/platforms/windows/visualstudio/test_package.py b/tests/platforms/windows/visualstudio/test_package.py
index 8f3f161a0..3db8425f4 100644
--- a/tests/platforms/windows/visualstudio/test_package.py
+++ b/tests/platforms/windows/visualstudio/test_package.py
@@ -1,8 +1,6 @@
 # The package command inherits most of its behavior from the common base
 # implementation. Do a surface-level verification here, but the app
 # tests provide the actual test coverage.
-import os
-from pathlib import Path
 from unittest import mock
 
 import pytest
@@ -30,6 +28,9 @@ def test_package_msi(package_command, first_app_config, tmp_path):
 
     package_command.package_app(first_app_config)
 
+    package_path = (
+        tmp_path / "base_path/build/first-app/windows/visualstudio/x64/Release"
+    )
     package_command.tools.subprocess.run.assert_has_calls(
         [
             # Collect manifest
@@ -37,7 +38,7 @@ def test_package_msi(package_command, first_app_config, tmp_path):
                 [
                     tmp_path / "wix/bin/heat.exe",
                     "dir",
-                    Path("x64/Release"),
+                    package_path,
                     "-nologo",
                     "-gg",
                     "-sfrag",
@@ -72,7 +73,7 @@ def test_package_msi(package_command, first_app_config, tmp_path):
                     "WixUIExtension",
                     "-arch",
                     "x64",
-                    f"-dSourceDir={os.fsdecode(Path('x64/Release'))}",
+                    f"-dSourceDir={package_path}",
                     "first-app.wxs",
                     "first-app-manifest.wxs",
                 ],