From 8f0473bf0565b6b86bc07ea40de1168b0d301448 Mon Sep 17 00:00:00 2001 From: Siddhant Bayas Date: Thu, 6 Aug 2026 23:35:52 +0530 Subject: [PATCH 01/14] feat: add support for exporting a GPG secret key Add GnuPG.export_secret_key(), which exports a secret key from the local keyring to a file. This will be used to inject a signing identity into a Docker container. --- src/briefcase/integrations/gnupg.py | 27 +++++++++++ .../gnupg/test_GnuPG__export_secret_key.py | 46 +++++++++++++++++++ 2 files changed, 73 insertions(+) create mode 100644 tests/integrations/gnupg/test_GnuPG__export_secret_key.py diff --git a/src/briefcase/integrations/gnupg.py b/src/briefcase/integrations/gnupg.py index 0e339e8aa..4728716c4 100644 --- a/src/briefcase/integrations/gnupg.py +++ b/src/briefcase/integrations/gnupg.py @@ -1,6 +1,7 @@ from __future__ import annotations import subprocess +from pathlib import Path from briefcase.exceptions import BriefcaseCommandError from briefcase.integrations.base import Tool, ToolCache @@ -66,3 +67,29 @@ def identities(self) -> dict[str, str]: identities[fingerprint] = record[9] return identities + + def export_secret_key(self, identity: str, output_path: Path): + """Export a secret key to a file. + + The exported key can be imported into a different environment (e.g., a Docker + container) to enable signing there. + + :param identity: The fingerprint of the identity to export + :param output_path: The path of the file to write the exported key to + """ + try: + self.tools.subprocess.run( + [ + "gpg", + "--batch", + "--output", + output_path, + "--export-secret-keys", + identity, + ], + check=True, + ) + except subprocess.CalledProcessError as e: + raise BriefcaseCommandError( + f"Error exporting the GPG signing key for identity {identity}." + ) from e diff --git a/tests/integrations/gnupg/test_GnuPG__export_secret_key.py b/tests/integrations/gnupg/test_GnuPG__export_secret_key.py new file mode 100644 index 000000000..555d697f2 --- /dev/null +++ b/tests/integrations/gnupg/test_GnuPG__export_secret_key.py @@ -0,0 +1,46 @@ +import subprocess +from pathlib import Path +from unittest import mock + +import pytest + +from briefcase.exceptions import BriefcaseCommandError +from briefcase.integrations.subprocess import Subprocess + +from .conftest import JANE + + +def test_export_secret_key(mock_tools, gpg): + """A secret key is exported to a file.""" + mock_tools.subprocess = mock.MagicMock(spec_set=Subprocess) + output_path = Path("/path/to/key.gpg") + + gpg.export_secret_key(JANE, output_path) + + mock_tools.subprocess.run.assert_called_once_with( + [ + "gpg", + "--batch", + "--output", + output_path, + "--export-secret-keys", + JANE, + ], + check=True, + ) + + +def test_export_secret_key_error(mock_tools, gpg): + """If the key can't be exported, an error is raised.""" + mock_tools.subprocess = mock.MagicMock(spec_set=Subprocess) + mock_tools.subprocess.run.side_effect = subprocess.CalledProcessError( + returncode=1, + cmd=["gpg", "--batch", "--export-secret-keys", JANE], + ) + output_path = Path("/path/to/key.gpg") + + with pytest.raises( + BriefcaseCommandError, + match=rf"Error exporting the GPG signing key for identity {JANE}\.", + ): + gpg.export_secret_key(JANE, output_path) From 5928c591cc5a7434de8e9cae25a413f7211e37f0 Mon Sep 17 00:00:00 2001 From: Siddhant Bayas Date: Thu, 6 Aug 2026 23:36:18 +0530 Subject: [PATCH 02/14] feat: install the signing tool in the Linux system Docker image The Docker image is built before the signing identity is selected, so the signing tool (debsigs, rpm-sign, or gnupg) is added to SYSTEM_REQUIRES for every image build. On SUSE, no rpm-sign package is installed, as rpmsign is provided by rpm-build, which is already part of the image. The "system" packaging format is also resolved to its concrete format before the app context is verified, so the image can be built with the tools needed to package and sign the app. --- src/briefcase/platforms/linux/system.py | 51 ++++++++++- .../linux/system/test_mixin__verify.py | 87 +++++++++++++++++++ tests/platforms/linux/system/test_package.py | 67 ++++++++++++++ .../linux/system/test_package__deb.py | 3 + .../linux/system/test_package__pkg.py | 3 + .../linux/system/test_package__rpm.py | 3 + 6 files changed, 213 insertions(+), 1 deletion(-) diff --git a/src/briefcase/platforms/linux/system.py b/src/briefcase/platforms/linux/system.py index dc44e6d0e..4659d4a93 100644 --- a/src/briefcase/platforms/linux/system.py +++ b/src/briefcase/platforms/linux/system.py @@ -727,6 +727,41 @@ def verify_system_python(self): f"({system_version!r})." ) + def _docker_signing_tool(self, app: LinuxSystemAppConfig) -> list[str]: + """Utility method returning the packages needed to install the signing tool in a + Docker image. + + The Docker image is built before the signing identity is selected, so the + signing tool must be installed in the image for the signing step to be able to + run inside the container. + + :param app: The app being packaged + :returns: The list of packages that must be installed in the Docker image to + provide the signing tool. + """ + packaging_format = getattr(app, "packaging_format", None) + if packaging_format == "system": + packaging_format = { + DEBIAN: "deb", + RHEL: "rpm", + ARCH: "pkg", + SUSE: "rpm", + }.get(app.target_vendor_base) + + package_name = { + "deb": "debsigs", + "rpm": "rpm-sign", + "pkg": "gnupg", + }.get(packaging_format) + + if package_name is None or ( + package_name == "rpm-sign" and app.target_vendor_base == SUSE + ): + # On SUSE, rpmsign is provided by rpm-build, which is already + # installed by the Docker image; there is no `rpm-sign` package. + return [] + return [package_name] + def verify_app_tools(self, app: FinalizedAppConfig): """Verify App environment is prepared and available. @@ -742,6 +777,17 @@ def verify_app_tools(self, app: FinalizedAppConfig): verify_python = not hasattr(self.tools[app], "app_context") if self.use_docker: + # The Docker image is built before the signing identity is selected, + # so the signing tool must be installed in the image for the signing + # step to be able to run inside the container. + system_requires = getattr(app, "system_requires", None) + if system_requires is None: + system_requires = [] + app.system_requires = system_requires + for package in self._docker_signing_tool(app): + if package not in system_requires: + system_requires.append(package) + DockerAppContext.verify( tools=self.tools, app=app, @@ -1307,8 +1353,9 @@ def _verify_packaging_tools(self, app: LinuxSystemAppConfig): def verify_app_tools(self, app: FinalizedAppConfig): app = cast(LinuxSystemAppConfig, app) - super().verify_app_tools(app) # If "system" packaging format was selected, determine what that means. + # This must be done before the app context is verified, so the Docker + # image can be built with the tools needed to package and sign the app. if app.packaging_format == "system": app.packaging_format = { DEBIAN: "deb", @@ -1324,6 +1371,8 @@ def verify_app_tools(self, app: FinalizedAppConfig): "by manually specifying a format with -p/--packaging-format" ) + super().verify_app_tools(app) + if not self.use_docker: self._verify_packaging_tools(app) diff --git a/tests/platforms/linux/system/test_mixin__verify.py b/tests/platforms/linux/system/test_mixin__verify.py index 2a64b39c6..4d46f77ad 100644 --- a/tests/platforms/linux/system/test_mixin__verify.py +++ b/tests/platforms/linux/system/test_mixin__verify.py @@ -1,5 +1,7 @@ from unittest.mock import MagicMock +import pytest + import briefcase.platforms.linux.system from briefcase.integrations.docker import Docker, DockerAppContext from briefcase.integrations.subprocess import Subprocess @@ -116,6 +118,91 @@ def test_linux_docker(create_command, first_app_config, tmp_path, monkeypatch): create_command.verify_docker_python.assert_not_called() +@pytest.mark.parametrize( + ("vendor_base", "packaging_format", "expected_requires"), + [ + # The signing tool is added to the image requirements for a known format + ("debian", "system", ["debsigs"]), + ("rhel", "system", ["rpm-sign"]), + ("arch", "system", ["gnupg"]), + # On SUSE, rpmsign is provided by rpm-build, which is already installed + # by the Docker image; no additional package is needed. + ("suse", "system", []), + # An unknown vendor resolves to no packaging format; no signing tool + ("basevendor", "system", []), + # A concrete packaging format is used as-is + ("debian", "deb", ["debsigs"]), + ], +) +def test_linux_docker_adds_signing_tool( + create_command, + first_app_config, + tmp_path, + monkeypatch, + vendor_base, + packaging_format, + expected_requires, +): + """If Docker is enabled on Linux, the signing tool is added to the image + requirements. + + This must happen during any command's app tool verification, because the + Docker image is built before the signing identity is selected; if the + signing tool isn't in the image, signing a package built with Docker will + fail. + """ + create_command.tools.host_os = "Linux" + create_command.target_image = "somevendor:surprising" + create_command.extra_docker_build_args = [] + + # Force a dummy vendor:codename for test purposes. + first_app_config.target_vendor = "somevendor" + first_app_config.target_codename = "surprising" + first_app_config.target_vendor_base = vendor_base + first_app_config.packaging_format = packaging_format + first_app_config.python_version_tag = "3" + + # Mock Docker tool verification + mock__version_compat = MagicMock(spec=Docker._version_compat) + mock__user_access = MagicMock(spec=Docker._user_access) + mock__buildx_installed = MagicMock(spec=Docker._buildx_installed) + mock__is_user_mapping_enabled = MagicMock(spec=Docker._is_user_mapping_enabled) + monkeypatch.setattr( + briefcase.platforms.linux.system.Docker, + "_version_compat", + mock__version_compat, + ) + monkeypatch.setattr( + briefcase.platforms.linux.system.Docker, + "_user_access", + mock__user_access, + ) + monkeypatch.setattr( + briefcase.platforms.linux.system.Docker, + "_buildx_installed", + mock__buildx_installed, + ) + monkeypatch.setattr( + briefcase.platforms.linux.system.Docker, + "_is_user_mapping_enabled", + mock__is_user_mapping_enabled, + ) + mock_docker_app_context_verify = MagicMock(spec=DockerAppContext.verify) + monkeypatch.setattr( + briefcase.platforms.linux.system.DockerAppContext, + "verify", + mock_docker_app_context_verify, + ) + create_command.verify_docker_python = MagicMock() + + # Verify the tools + create_command.verify_tools() + create_command.verify_app_tools(app=first_app_config) + + # The signing tool has been added to the image requirements + assert getattr(first_app_config, "system_requires", None) == expected_requires + + def test_non_linux_docker(create_command, first_app_config, tmp_path, monkeypatch): """If Docker is enabled on non-Linux, the Docker alias is set.""" create_command.tools.host_os = "Darwin" diff --git a/tests/platforms/linux/system/test_package.py b/tests/platforms/linux/system/test_package.py index 5b03faa43..08a864236 100644 --- a/tests/platforms/linux/system/test_package.py +++ b/tests/platforms/linux/system/test_package.py @@ -165,6 +165,73 @@ def test_unknown_packaging_format(package_command, first_app): package_command.verify_app_tools(first_app) +@pytest.mark.parametrize( + ("base_vendor", "input_format", "output_format", "expected_requires"), + [ + # System packaging maps to known formats, and adds the signing tool + ("debian", "system", "deb", ["debsigs"]), + ("rhel", "system", "rpm", ["rpm-sign"]), + ("arch", "system", "pkg", ["gnupg"]), + # Explicit output format is preserved, and adds the signing tool + ("debian", "deb", "deb", ["debsigs"]), + ("redhat", "rpm", "rpm", ["rpm-sign"]), + ("arch", "pkg", "pkg", ["gnupg"]), + # On SUSE, rpmsign is provided by rpm-build, which is already installed + # by the Docker image; no additional package is needed. + ("suse", "system", "rpm", []), + ], +) +def test_docker_packaging_format_adjusts_signing_tools( + package_command, + first_app, + base_vendor, + input_format, + output_format, + expected_requires, +): + """When using Docker, the signing tool is added to the image requirements.""" + first_app.target_vendor_base = base_vendor + first_app.packaging_format = input_format + package_command.target_image = "somevendor:surprising" + package_command.extra_docker_build_args = [] + package_command.verify_docker_python = mock.MagicMock() + package_command.tools[first_app].app_context = mock.MagicMock() + + package_command.verify_app_tools(first_app) + + assert first_app.packaging_format == output_format + assert getattr(first_app, "system_requires", []) == expected_requires + + +def test_docker_packaging_format_signing_tools_are_not_duplicated( + package_command, + first_app, +): + """The signing tool is not added to the image requirements more than once.""" + first_app.target_vendor_base = "debian" + first_app.packaging_format = "deb" + package_command.target_image = "somevendor:surprising" + package_command.extra_docker_build_args = [] + package_command.verify_docker_python = mock.MagicMock() + package_command.tools[first_app].app_context = mock.MagicMock() + + package_command.verify_app_tools(first_app) + package_command.verify_app_tools(first_app) + + assert first_app.system_requires == ["debsigs"] + + +def test_native_packaging_does_not_add_signing_tools(package_command, first_app): + """The signing tool is not added to the host requirements when not using + Docker.""" + first_app.target_vendor_base = "debian" + first_app.packaging_format = "deb" + + package_command.verify_app_tools(first_app) + + assert getattr(first_app, "system_requires", None) is None + + def test_package_deb_app(package_command, first_app, mock_gpg): """A debian app can be packaged.""" # Set the packaging format diff --git a/tests/platforms/linux/system/test_package__deb.py b/tests/platforms/linux/system/test_package__deb.py index 4bfaa8950..862a9c550 100644 --- a/tests/platforms/linux/system/test_package__deb.py +++ b/tests/platforms/linux/system/test_package__deb.py @@ -154,6 +154,9 @@ def test_verify_docker(package_command, first_app_deb, monkeypatch): # dpkg_deb was not inspected dpkg_deb.exists.assert_not_called() + # The signing tool has been added to the image requirements + assert first_app_deb.system_requires == ["debsigs"] + @pytest.mark.skipif(sys.platform == "win32", reason="Can't build debs on Windows") def test_deb_package(package_command, first_app_deb, mock_gpg, tmp_path): diff --git a/tests/platforms/linux/system/test_package__pkg.py b/tests/platforms/linux/system/test_package__pkg.py index ace2c7386..33cbf1409 100644 --- a/tests/platforms/linux/system/test_package__pkg.py +++ b/tests/platforms/linux/system/test_package__pkg.py @@ -161,6 +161,9 @@ def test_verify_docker(package_command, first_app_pkg, monkeypatch): # makepkg was not inspected makepkg.exists.assert_not_called() + # The signing tool has been added to the image requirements + assert first_app_pkg.system_requires == ["gnupg"] + @pytest.mark.parametrize( "changelog_filename", diff --git a/tests/platforms/linux/system/test_package__rpm.py b/tests/platforms/linux/system/test_package__rpm.py index 8274d392f..63eba3043 100644 --- a/tests/platforms/linux/system/test_package__rpm.py +++ b/tests/platforms/linux/system/test_package__rpm.py @@ -161,6 +161,9 @@ def test_verify_docker(package_command, first_app_rpm, monkeypatch): # rpmbuild was not inspected rpmbuild.exists.assert_not_called() + # The signing tool has been added to the image requirements + assert first_app_rpm.system_requires == ["rpm-sign"] + @pytest.mark.parametrize( "changelog_filename", From 7e223a686f28881a6a11d976682b6a9b90aa4953 Mon Sep 17 00:00:00 2001 From: Siddhant Bayas Date: Thu, 6 Aug 2026 23:36:27 +0530 Subject: [PATCH 03/14] feat: sign Linux system packages inside the Docker container When the package is built inside Docker, the selected secret key is exported from the host's GPG keyring to a file in the data path (which is mounted into the container), then imported and used to sign the package in a single container run. The exported key is deleted immediately after signing, so it is never stored in the image. The dist folder is mounted into the container for the duration of the signing step. --- src/briefcase/platforms/linux/system.py | 43 ++++-- .../linux/system/signing/test_package_app.py | 32 ++--- .../linux/system/signing/test_sign_package.py | 135 ++++++++++++++++++ 3 files changed, 182 insertions(+), 28 deletions(-) diff --git a/src/briefcase/platforms/linux/system.py b/src/briefcase/platforms/linux/system.py index 4659d4a93..5aef22ae4 100644 --- a/src/briefcase/platforms/linux/system.py +++ b/src/briefcase/platforms/linux/system.py @@ -2,11 +2,12 @@ import gzip import re +import shlex import subprocess import tarfile from collections.abc import Collection from pathlib import Path -from typing import cast +from typing import Any, cast from briefcase.commands import ( BuildCommand, @@ -1248,13 +1249,45 @@ def sign_package(self, app: LinuxSystemAppConfig, identity: str): ], }[app.packaging_format] + subprocess_kwargs: dict[str, Any] = {} + key_file_path: Path | None = None + if isinstance(self.tools[app].app_context, DockerAppContext): + # When packaging inside Docker, the secret key must be made available + # to the container. Export the key to the data path (which is mounted + # into the container), then import it and sign the package in a single + # container run, so the key is not retained in the image or container. + key_file_path = self.data_path / f"{app.app_name}-signing-key.gpg" + subprocess_kwargs["mounts"] = [(self.dist_path, "/dist")] + try: - self.tools[app].app_context.run(sign_command, check=True) + if key_file_path is not None: + self.tools.gnupg.export_secret_key(identity, key_file_path) + self.tools.os.chmod(key_file_path, 0o600) + sign_command = [ + "sh", + "-c", + " && ".join( + " ".join(shlex.quote(arg) for arg in command) + for command in [ + ["gpg", "--batch", "--import", str(key_file_path)], + sign_command, + ] + ), + ] + + self.tools[app].app_context.run( + sign_command, + check=True, + **subprocess_kwargs, + ) except subprocess.CalledProcessError as e: raise BriefcaseCommandError( f"Error while signing .{app.packaging_format} package for " f"{app.app_name}." ) from e + finally: + if key_file_path is not None: + key_file_path.unlink(missing_ok=True) def clean_dist_folder(self, app, **options): super().clean_dist_folder(app, **options) @@ -1283,12 +1316,6 @@ def package_app(self, app, identity=None, adhoc_sign=False, **kwargs): else: identity = self.select_identity(identity=identity) if identity: - if self.use_docker: - raise BriefcaseCommandError( - "Signing system packages is not supported when using " - "Docker. Re-run the package command without the " - "`--target` option, or select `Don't sign`." - ) # Signing is required; verify the signing tool is available. self._verify_signing_tool(app) else: diff --git a/tests/platforms/linux/system/signing/test_package_app.py b/tests/platforms/linux/system/signing/test_package_app.py index 7bdac11d4..77cc879bd 100644 --- a/tests/platforms/linux/system/signing/test_package_app.py +++ b/tests/platforms/linux/system/signing/test_package_app.py @@ -179,12 +179,12 @@ def test_package_app_unknown_format_signs(package_command, first_app, mock_gpg): package_command.sign_package.assert_not_called() -def test_signs_raises_in_docker( +def test_package_app_signs_in_docker( package_command, first_app, mock_gpg, ): - """Signing is not supported when building with Docker.""" + """If an identity is available, a Docker build is signed with it.""" first_app.packaging_format = "deb" mock_gpg.identities.return_value = { JANE: "Jane Doe ", @@ -196,23 +196,19 @@ def test_signs_raises_in_docker( # Accept the default selection (the single available identity) package_command.console.values = [""] - with pytest.raises( - BriefcaseCommandError, - match=r"Signing system packages is not supported when using Docker", - ): - package_command.package_app(first_app) + package_command.package_app(first_app) - package_command._package_deb.assert_not_called() - package_command._verify_signing_tool.assert_not_called() - package_command.sign_package.assert_not_called() + package_command._package_deb.assert_called_once_with(first_app) + package_command._verify_signing_tool.assert_called_once_with(first_app) + package_command.sign_package.assert_called_once_with(first_app, identity=JANE) -def test_explicit_identity_raises_in_docker( +def test_package_app_explicit_identity_in_docker( package_command, first_app, mock_gpg, ): - """An explicit identity is rejected when building with Docker.""" + """An explicit identity is used to sign a Docker build.""" first_app.packaging_format = "deb" mock_gpg.identities.return_value = { JANE: "Jane Doe ", @@ -223,15 +219,11 @@ def test_explicit_identity_raises_in_docker( package_command.sign_package = mock.MagicMock() package_command.target_image = "debian:bookworm" - with pytest.raises( - BriefcaseCommandError, - match=r"Signing system packages is not supported when using Docker", - ): - package_command.package_app(first_app, identity="jane@example.com") + package_command.package_app(first_app, identity="jane@example.com") - package_command._package_deb.assert_not_called() - package_command._verify_signing_tool.assert_not_called() - package_command.sign_package.assert_not_called() + package_command._package_deb.assert_called_once_with(first_app) + package_command._verify_signing_tool.assert_called_once_with(first_app) + package_command.sign_package.assert_called_once_with(first_app, identity=JANE) def test_package_app_dont_sign_in_docker(package_command, first_app, mock_gpg): diff --git a/tests/platforms/linux/system/signing/test_sign_package.py b/tests/platforms/linux/system/signing/test_sign_package.py index 3c8ff60bf..d8eb03af8 100644 --- a/tests/platforms/linux/system/signing/test_sign_package.py +++ b/tests/platforms/linux/system/signing/test_sign_package.py @@ -1,3 +1,4 @@ +import shlex import subprocess from pathlib import Path from unittest import mock @@ -5,10 +6,19 @@ import pytest from briefcase.exceptions import BriefcaseCommandError +from briefcase.integrations.docker import DockerAppContext from .conftest import JANE +def make_docker_context(package_command, first_app): + """Replace the app context with a Docker context with a mocked run method.""" + app_context = DockerAppContext(tools=package_command.tools, app=first_app) + app_context.run = mock.MagicMock() + package_command.tools[first_app].app_context = app_context + return app_context + + def test_sign_deb_package(package_command, first_app): """A .deb package is signed with debsigs.""" first_app.packaging_format = "deb" @@ -86,3 +96,128 @@ def test_sign_package_error(package_command, first_app): match=r"Error while signing .deb package for first-app.", ): package_command.sign_package(first_app, identity=JANE) + + +@pytest.mark.parametrize( + ("format", "sign_command"), + [ + ( + "deb", + [ + "debsigs", + "--sign=origin", + f"--default-key={JANE}", + "/path/to/dist/first-app.deb", + ], + ), + ( + "rpm", + [ + "rpmsign", + "--define", + f"_gpg_name {JANE}", + "--addsign", + "/path/to/dist/first-app.rpm", + ], + ), + ( + "pkg", + [ + "gpg", + "--detach-sign", + "-u", + JANE, + "--output", + "/path/to/dist/first-app.pkg.tar.zst.sig", + "/path/to/dist/first-app.pkg.tar.zst", + ], + ), + ], +) +def test_sign_package_in_docker( + package_command, + first_app, + mock_gpg, + format, + sign_command, +): + """A package is signed inside a Docker container after importing the signing key.""" + first_app.packaging_format = format + dist_path = Path("/path/to/dist/first-app.pkg.tar.zst") + if format == "deb": + dist_path = Path("/path/to/dist/first-app.deb") + elif format == "rpm": + dist_path = Path("/path/to/dist/first-app.rpm") + package_command.distribution_path = mock.MagicMock(return_value=dist_path) + if format == "pkg": + package_command.signature_path = mock.MagicMock( + return_value=Path("/path/to/dist/first-app.pkg.tar.zst.sig") + ) + make_docker_context(package_command, first_app) + + key_file_path = package_command.data_path / f"{first_app.app_name}-signing-key.gpg" + key_file_path.touch() + + package_command.sign_package(first_app, identity=JANE) + + mock_gpg.export_secret_key.assert_called_once_with(JANE, key_file_path) + package_command.tools.os.chmod.assert_called_once_with(key_file_path, 0o600) + + import_command = ["gpg", "--batch", "--import", str(key_file_path)] + command = " && ".join( + " ".join(shlex.quote(arg) for arg in cmd) + for cmd in [import_command, sign_command] + ) + package_command.tools[first_app].app_context.run.assert_called_once_with( + ["sh", "-c", command], + check=True, + mounts=[(package_command.dist_path, "/dist")], + ) + + # The exported key file is removed after signing. + assert not key_file_path.exists() + + +def test_sign_package_error_in_docker(package_command, first_app, mock_gpg): + """If signing inside Docker fails, an error is raised and the key file is removed.""" + first_app.packaging_format = "deb" + package_command.distribution_path = mock.MagicMock( + return_value=Path("/path/to/dist/first-app.deb") + ) + app_context = make_docker_context(package_command, first_app) + app_context.run.side_effect = subprocess.CalledProcessError( + returncode=1, + cmd=["sh", "-c", "gpg --batch --import /path/to/key && debsigs"], + ) + + key_file_path = package_command.data_path / f"{first_app.app_name}-signing-key.gpg" + key_file_path.touch() + + with pytest.raises( + BriefcaseCommandError, + match=r"Error while signing .deb package for first-app.", + ): + package_command.sign_package(first_app, identity=JANE) + + # The exported key file is removed after signing. + assert not key_file_path.exists() + + +def test_sign_package_key_export_error_in_docker(package_command, first_app, mock_gpg): + """If the signing key can't be exported, an error is raised and the key file is + removed.""" + first_app.packaging_format = "deb" + package_command.distribution_path = mock.MagicMock( + return_value=Path("/path/to/dist/first-app.deb") + ) + make_docker_context(package_command, first_app) + mock_gpg.export_secret_key.side_effect = BriefcaseCommandError("boom") + + key_file_path = package_command.data_path / f"{first_app.app_name}-signing-key.gpg" + key_file_path.touch() + + with pytest.raises(BriefcaseCommandError, match=r"boom"): + package_command.sign_package(first_app, identity=JANE) + + package_command.tools[first_app].app_context.run.assert_not_called() + assert not key_file_path.exists() From 2d48aeec6e656c3589d881b99b5f620b174096fe Mon Sep 17 00:00:00 2001 From: Siddhant Bayas Date: Thu, 6 Aug 2026 23:36:36 +0530 Subject: [PATCH 04/14] feat: suggest --adhoc-sign when the signing tool is missing For consistency with other platforms, the signing tool error now hints that the package can be produced unsigned with --adhoc-sign. --- src/briefcase/platforms/linux/system.py | 6 ++++-- .../linux/system/signing/test_verify_signing_tool.py | 8 ++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/briefcase/platforms/linux/system.py b/src/briefcase/platforms/linux/system.py index 5aef22ae4..f5f24d64f 100644 --- a/src/briefcase/platforms/linux/system.py +++ b/src/briefcase/platforms/linux/system.py @@ -1137,12 +1137,14 @@ def _verify_signing_tool(self, app: LinuxSystemAppConfig): if install_cmd := self._system_requirement_tools(app)[3]: raise BriefcaseCommandError( f"Can't find the {tool_name} tools. " - f"Try running `sudo {' '.join(install_cmd)} {package_name}`." + f"Try running `sudo {' '.join(install_cmd)} {package_name}`. " + "Alternatively, use `--adhoc-sign` to skip signing the package." ) from None else: raise BriefcaseCommandError( f"Can't find the {executable_name} tool. " - f"Install this first to sign the {app.packaging_format}." + f"Install this first to sign the {app.packaging_format}. " + "Alternatively, use `--adhoc-sign` to skip signing the package." ) from None def signature_path(self, app: LinuxSystemAppConfig) -> Path: diff --git a/tests/platforms/linux/system/signing/test_verify_signing_tool.py b/tests/platforms/linux/system/signing/test_verify_signing_tool.py index 919241123..5bd49540b 100644 --- a/tests/platforms/linux/system/signing/test_verify_signing_tool.py +++ b/tests/platforms/linux/system/signing/test_verify_signing_tool.py @@ -48,7 +48,8 @@ def test_verify_signing_tool_missing( BriefcaseCommandError, match=( rf"Can't find the {tool_name} tools. " - rf"Try running `sudo apt install {package_name}`." + rf"Try running `sudo apt install {package_name}`. " + r"Alternatively, use `--adhoc-sign` to skip signing the package." ), ): package_command._verify_signing_tool(first_app) @@ -68,7 +69,10 @@ def test_verify_signing_tool_missing_unknown_vendor(package_command, first_app): with pytest.raises( BriefcaseCommandError, - match=r"Can't find the debsigs tool. Install this first to sign the deb.", + match=( + r"Can't find the debsigs tool. Install this first to sign the deb. " + r"Alternatively, use `--adhoc-sign` to skip signing the package." + ), ): package_command._verify_signing_tool(first_app) From 054b2f766f2aef35cb877b5442d84ebfe72bc15e Mon Sep 17 00:00:00 2001 From: Siddhant Bayas Date: Thu, 6 Aug 2026 23:36:43 +0530 Subject: [PATCH 05/14] docs: document signing Linux system packages built with Docker --- changes/2396.feature.md | 2 +- changes/2984.feature.md | 1 + docs/en/how-to/code-signing/linux.md | 6 +++++- docs/en/reference/platforms/linux/system.md | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) create mode 100644 changes/2984.feature.md diff --git a/changes/2396.feature.md b/changes/2396.feature.md index 7267ac472..3ea1c7668 100644 --- a/changes/2396.feature.md +++ b/changes/2396.feature.md @@ -1 +1 @@ -Linux system packages (`.deb`, `.rpm` and `.pkg.tar.zst`) can now be signed with a GPG signing identity, using the `--identity` option to `briefcase package`. Signing is not currently supported when building with Docker. +Linux system packages (`.deb`, `.rpm` and `.pkg.tar.zst`) can now be signed with a GPG signing identity, using the `--identity` option to `briefcase package`. diff --git a/changes/2984.feature.md b/changes/2984.feature.md new file mode 100644 index 000000000..f3ba85f94 --- /dev/null +++ b/changes/2984.feature.md @@ -0,0 +1 @@ +Linux system packages (`.deb`, `.rpm` and `.pkg.tar.zst`) can now be signed when building with Docker, using the same GPG signing identity as native builds. The signing identity is exported from the host and imported into the build container for the duration of the signing step. diff --git a/docs/en/how-to/code-signing/linux.md b/docs/en/how-to/code-signing/linux.md index 8943a5bab..47d3eb5e4 100644 --- a/docs/en/how-to/code-signing/linux.md +++ b/docs/en/how-to/code-signing/linux.md @@ -64,4 +64,8 @@ As with other platforms, `--adhoc-sign` is useful during development and testing ## Docker builds -Signing is not currently supported when building with Docker (i.e., when the `--target` option is used). When packaging with Docker, you must opt out of signing — either by selecting "Don't sign" when prompted, or by providing the `--adhoc-sign` option. Selecting a signing identity when building with Docker will cause an error. +Linux system packages can be signed when building with Docker (i.e., when the `--target` option is used), using the same GPG signing identity as native builds. + +When signing a package built with Docker, Briefcase exports the selected secret key from the host machine's GPG keyring, and imports it into the build container so that the signing step can run inside the container. The exported key is removed immediately after signing, and is never stored in the Docker image. + +One caveat applies when building with Docker: because the signing step runs inside a headless container, GnuPG is not able to prompt for a passphrase. If your signing key requires a passphrase, the signing step will fail. To sign packages built with Docker, use a key (or subkey) that does not require a passphrase, or build the package without the `--target` option and sign it natively. diff --git a/docs/en/reference/platforms/linux/system.md b/docs/en/reference/platforms/linux/system.md index 967ac1e42..d67ea320c 100644 --- a/docs/en/reference/platforms/linux/system.md +++ b/docs/en/reference/platforms/linux/system.md @@ -105,7 +105,7 @@ Signing is performed as follows, depending on the packaging format: If the relevant signing tool is not installed, Briefcase will report an error suggesting how to install it. If no signing identity is available, or if `--adhoc-sign` is used, the package will be produced without a signature. -Signing is not supported when building with Docker (i.e., using the `--target` option); in this case, the package must be produced without a signature. +When building with Docker, the signing tool is installed in the build container, and the signing identity is exported from the host and imported into the container for the duration of the signing step. Note that a key requiring a passphrase cannot be used to sign a package built with Docker, as GnuPG cannot prompt for a passphrase inside the container. ## Additional options From 9c7f3cab9d35df0844f3b26812f11a157ce21936 Mon Sep 17 00:00:00 2001 From: Siddhant Bayas Date: Fri, 7 Aug 2026 15:48:26 +0530 Subject: [PATCH 06/14] docs: fix docstring formatting in tests --- tests/platforms/linux/system/signing/test_sign_package.py | 3 ++- tests/platforms/linux/system/test_mixin__verify.py | 7 +++---- tests/platforms/linux/system/test_package.py | 3 +-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/tests/platforms/linux/system/signing/test_sign_package.py b/tests/platforms/linux/system/signing/test_sign_package.py index d8eb03af8..9a51fa4de 100644 --- a/tests/platforms/linux/system/signing/test_sign_package.py +++ b/tests/platforms/linux/system/signing/test_sign_package.py @@ -179,7 +179,8 @@ def test_sign_package_in_docker( def test_sign_package_error_in_docker(package_command, first_app, mock_gpg): - """If signing inside Docker fails, an error is raised and the key file is removed.""" + """If signing inside Docker fails, an error is raised and the key file is + removed.""" first_app.packaging_format = "deb" package_command.distribution_path = mock.MagicMock( return_value=Path("/path/to/dist/first-app.deb") diff --git a/tests/platforms/linux/system/test_mixin__verify.py b/tests/platforms/linux/system/test_mixin__verify.py index 4d46f77ad..935d38762 100644 --- a/tests/platforms/linux/system/test_mixin__verify.py +++ b/tests/platforms/linux/system/test_mixin__verify.py @@ -146,10 +146,9 @@ def test_linux_docker_adds_signing_tool( """If Docker is enabled on Linux, the signing tool is added to the image requirements. - This must happen during any command's app tool verification, because the - Docker image is built before the signing identity is selected; if the - signing tool isn't in the image, signing a package built with Docker will - fail. + This must happen during any command's app tool verification, because the Docker + image is built before the signing identity is selected; if the signing tool isn't in + the image, signing a package built with Docker will fail. """ create_command.tools.host_os = "Linux" create_command.target_image = "somevendor:surprising" diff --git a/tests/platforms/linux/system/test_package.py b/tests/platforms/linux/system/test_package.py index 08a864236..83c350e90 100644 --- a/tests/platforms/linux/system/test_package.py +++ b/tests/platforms/linux/system/test_package.py @@ -222,8 +222,7 @@ def test_docker_packaging_format_signing_tools_are_not_duplicated( def test_native_packaging_does_not_add_signing_tools(package_command, first_app): - """The signing tool is not added to the host requirements when not using - Docker.""" + """The signing tool is not added to the host requirements when not using Docker.""" first_app.target_vendor_base = "debian" first_app.packaging_format = "deb" From fd313be395a7b746bac592d55119b8610d0b6df1 Mon Sep 17 00:00:00 2001 From: Siddhant Bayas Date: Mon, 10 Aug 2026 17:10:45 +0530 Subject: [PATCH 07/14] docs: add subkey to spelling wordlist --- docs/spelling_wordlist | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/spelling_wordlist b/docs/spelling_wordlist index d3f7853f5..d945a67e0 100644 --- a/docs/spelling_wordlist +++ b/docs/spelling_wordlist @@ -204,6 +204,7 @@ stylesheet subdirectories subdirectory subfolders +subkey submodule subprocess subprocesses From 563ca8712ba813a3cb394c19413a288e1326d356 Mon Sep 17 00:00:00 2001 From: Siddhant Bayas Date: Wed, 12 Aug 2026 17:20:20 +0530 Subject: [PATCH 08/14] Rework Docker signing for Linux system packages Address review feedback on the Docker signing implementation: * Move _signing_tool to LinuxSystemMixin and unify it with _docker_signing_tool, sharing a single _SIGNING_TOOLS mapping and a _SYSTEM_PACKAGING_FORMATS mapping. * Select the Docker key handling based on use_docker, rather than inspecting the app context type. * Export the signing key to the bundle path, and use container paths (/app, /dist) for the key import and package signing steps so the signing step works correctly with Windows paths on the host. * Fix vendor base names in tests ('redhat' -> 'rhel'). * Document the passphrase requirement for keys used with Docker builds. * Drop the separate changelog fragment; signing is covered by the existing fragment. --- changes/2984.feature.md | 1 - docs/en/how-to/code-signing/linux.md | 4 +- docs/spelling_wordlist | 1 - src/briefcase/platforms/linux/system.py | 101 ++++++++++-------- .../linux/system/signing/test_sign_package.py | 20 ++-- tests/platforms/linux/system/test_package.py | 4 +- 6 files changed, 74 insertions(+), 57 deletions(-) delete mode 100644 changes/2984.feature.md diff --git a/changes/2984.feature.md b/changes/2984.feature.md deleted file mode 100644 index f3ba85f94..000000000 --- a/changes/2984.feature.md +++ /dev/null @@ -1 +0,0 @@ -Linux system packages (`.deb`, `.rpm` and `.pkg.tar.zst`) can now be signed when building with Docker, using the same GPG signing identity as native builds. The signing identity is exported from the host and imported into the build container for the duration of the signing step. diff --git a/docs/en/how-to/code-signing/linux.md b/docs/en/how-to/code-signing/linux.md index 47d3eb5e4..1117d4ec6 100644 --- a/docs/en/how-to/code-signing/linux.md +++ b/docs/en/how-to/code-signing/linux.md @@ -14,6 +14,8 @@ $ gpg --full-generate-key You will be prompted to select a key type, key size and expiry date, and to provide a name and email address that will identify the key. If possible, use an ECC key based on Curve 25519 (an `ed25519` signing key), which is the default in recent GnuPG versions and produces smaller, faster signatures. If you need to support older tools that don't understand ECC keys, generate an RSA key of at least 4096 bits instead. The email address should be an address you control, as users will use it (along with your public key) to identify that the package really came from you. +If you plan to sign packages built with Docker, create a key that does not require a passphrase: when GnuPG prompts you to enter a passphrase, leave the field blank and confirm. When a signing key is exported to a Docker container, GnuPG cannot prompt for the passphrase, so a key with a passphrase will fail the signing step. See [Docker builds](#docker-builds) for details. + ## Obtain the identity of your key Briefcase uses the *fingerprint* of your key to identify the signing identity. To see the fingerprints of all the secret keys on your system, run: @@ -68,4 +70,4 @@ Linux system packages can be signed when building with Docker (i.e., when the `- When signing a package built with Docker, Briefcase exports the selected secret key from the host machine's GPG keyring, and imports it into the build container so that the signing step can run inside the container. The exported key is removed immediately after signing, and is never stored in the Docker image. -One caveat applies when building with Docker: because the signing step runs inside a headless container, GnuPG is not able to prompt for a passphrase. If your signing key requires a passphrase, the signing step will fail. To sign packages built with Docker, use a key (or subkey) that does not require a passphrase, or build the package without the `--target` option and sign it natively. +One caveat applies when building with Docker: because the signing step runs inside a headless container, GnuPG is not able to prompt for a passphrase. If your signing key requires a passphrase, the signing step will fail. To sign packages built with Docker, use a key (or sub-key) that does not require a passphrase, or build the package without the `--target` option and sign it natively. diff --git a/docs/spelling_wordlist b/docs/spelling_wordlist index d945a67e0..d3f7853f5 100644 --- a/docs/spelling_wordlist +++ b/docs/spelling_wordlist @@ -204,7 +204,6 @@ stylesheet subdirectories subdirectory subfolders -subkey submodule subprocess subprocesses diff --git a/src/briefcase/platforms/linux/system.py b/src/briefcase/platforms/linux/system.py index f5f24d64f..7c49f02eb 100644 --- a/src/briefcase/platforms/linux/system.py +++ b/src/briefcase/platforms/linux/system.py @@ -43,6 +43,22 @@ parse_freedesktop_os_release, ) +# The tools used to sign a package, keyed by packaging format. Each entry is a +# triple of (tool name, executable name, package name). +_SIGNING_TOOLS = { + "deb": ("debsigs", "debsigs", "debsigs"), + "rpm": ("rpmsign", "rpmsign", "rpm-sign"), + "pkg": ("gpg", "gpg", "gnupg"), +} + +# The packaging format implied by each system vendor base. +_SYSTEM_PACKAGING_FORMATS = { + DEBIAN: "deb", + RHEL: "rpm", + ARCH: "pkg", + SUSE: "rpm", +} + class LinuxSystemAppConfig(FinalizedAppConfig): """A FinalizedAppConfig with Linux system packaging attributes. @@ -389,6 +405,19 @@ def _system_requirement_tools(self, app: LinuxSystemAppConfig): system_installer, ) + def _signing_tool(self, app: LinuxSystemAppConfig) -> tuple[str, str, str]: + """Utility method returning the tool used to sign a package. + + :param app: The app being packaged + :returns: A triple of (tool name, executable name, package name) for the tool + used to sign the package. + :raises KeyError: If the packaging format cannot be determined. + """ + packaging_format = getattr(app, "packaging_format", None) + if packaging_format == "system": + packaging_format = _SYSTEM_PACKAGING_FORMATS.get(app.target_vendor_base) + return _SIGNING_TOOLS[packaging_format] + def verify_system_packages(self, app: LinuxSystemAppConfig): """Verify that the required system packages are installed. @@ -740,24 +769,13 @@ def _docker_signing_tool(self, app: LinuxSystemAppConfig) -> list[str]: :returns: The list of packages that must be installed in the Docker image to provide the signing tool. """ - packaging_format = getattr(app, "packaging_format", None) - if packaging_format == "system": - packaging_format = { - DEBIAN: "deb", - RHEL: "rpm", - ARCH: "pkg", - SUSE: "rpm", - }.get(app.target_vendor_base) - - package_name = { - "deb": "debsigs", - "rpm": "rpm-sign", - "pkg": "gnupg", - }.get(packaging_format) - - if package_name is None or ( - package_name == "rpm-sign" and app.target_vendor_base == SUSE - ): + try: + package_name = self._signing_tool(app)[2] + except KeyError: + # An unknown packaging format has no signing tool that can be identified. + return [] + + if package_name == "rpm-sign" and app.target_vendor_base == SUSE: # On SUSE, rpmsign is provided by rpm-build, which is already # installed by the Docker image; there is no `rpm-sign` package. return [] @@ -1104,19 +1122,6 @@ class LinuxSystemSigningMixin(_MixinBase): "available on the system." ) - def _signing_tool(self, app: LinuxSystemAppConfig) -> tuple[str, str, str]: - """Utility method returning the tool used to sign a package. - - :param app: The app being packaged - :returns: A triple of (tool name, executable name, package name) for the tool - used to sign the package. - """ - return { - "deb": ("debsigs", "debsigs", "debsigs"), - "rpm": ("rpmsign", "rpmsign", "rpm-sign"), - "pkg": ("gpg", "gpg", "gnupg"), - }[app.packaging_format] - def _verify_signing_tool(self, app: LinuxSystemAppConfig): """Verify that the app environment contains the signing tool. @@ -1253,13 +1258,24 @@ def sign_package(self, app: LinuxSystemAppConfig, identity: str): subprocess_kwargs: dict[str, Any] = {} key_file_path: Path | None = None - if isinstance(self.tools[app].app_context, DockerAppContext): + if self.use_docker: # When packaging inside Docker, the secret key must be made available - # to the container. Export the key to the data path (which is mounted - # into the container), then import it and sign the package in a single + # to the container. Export the key to the bundle path (which is mounted + # in to the container), then import it and sign the package in a single # container run, so the key is not retained in the image or container. - key_file_path = self.data_path / f"{app.app_name}-signing-key.gpg" + key_file_path = self.bundle_path(app) / "signing-key.gpg" subprocess_kwargs["mounts"] = [(self.dist_path, "/dist")] + # The bundle folder is mounted at /app, and the dist folder at /dist; + # rewrite the sign command to use the container paths directly. + signature_path = self.signature_path(app) + container_dist_path = f"/dist/{dist_path.name}" + container_signature_path = f"/dist/{signature_path.name}" + sign_command = [ + arg.replace(str(dist_path), container_dist_path).replace( + str(signature_path), container_signature_path + ) + for arg in sign_command + ] try: if key_file_path is not None: @@ -1271,7 +1287,12 @@ def sign_package(self, app: LinuxSystemAppConfig, identity: str): " && ".join( " ".join(shlex.quote(arg) for arg in command) for command in [ - ["gpg", "--batch", "--import", str(key_file_path)], + [ + "gpg", + "--batch", + "--import", + f"/app/{key_file_path.name}", + ], sign_command, ] ), @@ -1386,13 +1407,7 @@ def verify_app_tools(self, app: FinalizedAppConfig): # This must be done before the app context is verified, so the Docker # image can be built with the tools needed to package and sign the app. if app.packaging_format == "system": - app.packaging_format = { - DEBIAN: "deb", - RHEL: "rpm", - ARCH: "pkg", - SUSE: "rpm", - }.get(app.target_vendor_base) - + app.packaging_format = _SYSTEM_PACKAGING_FORMATS.get(app.target_vendor_base) if app.packaging_format is None: raise BriefcaseCommandError( "Briefcase doesn't know the system packaging format for " diff --git a/tests/platforms/linux/system/signing/test_sign_package.py b/tests/platforms/linux/system/signing/test_sign_package.py index 9a51fa4de..7113394ac 100644 --- a/tests/platforms/linux/system/signing/test_sign_package.py +++ b/tests/platforms/linux/system/signing/test_sign_package.py @@ -16,6 +16,8 @@ def make_docker_context(package_command, first_app): app_context = DockerAppContext(tools=package_command.tools, app=first_app) app_context.run = mock.MagicMock() package_command.tools[first_app].app_context = app_context + # Enable Docker for the command. + package_command.target_image = "somevendor:surprising" return app_context @@ -107,7 +109,7 @@ def test_sign_package_error(package_command, first_app): "debsigs", "--sign=origin", f"--default-key={JANE}", - "/path/to/dist/first-app.deb", + "/dist/first-app.deb", ], ), ( @@ -117,7 +119,7 @@ def test_sign_package_error(package_command, first_app): "--define", f"_gpg_name {JANE}", "--addsign", - "/path/to/dist/first-app.rpm", + "/dist/first-app.rpm", ], ), ( @@ -128,8 +130,8 @@ def test_sign_package_error(package_command, first_app): "-u", JANE, "--output", - "/path/to/dist/first-app.pkg.tar.zst.sig", - "/path/to/dist/first-app.pkg.tar.zst", + "/dist/first-app.pkg.tar.zst.sig", + "/dist/first-app.pkg.tar.zst", ], ), ], @@ -155,7 +157,7 @@ def test_sign_package_in_docker( ) make_docker_context(package_command, first_app) - key_file_path = package_command.data_path / f"{first_app.app_name}-signing-key.gpg" + key_file_path = package_command.bundle_path(first_app) / "signing-key.gpg" key_file_path.touch() package_command.sign_package(first_app, identity=JANE) @@ -163,7 +165,7 @@ def test_sign_package_in_docker( mock_gpg.export_secret_key.assert_called_once_with(JANE, key_file_path) package_command.tools.os.chmod.assert_called_once_with(key_file_path, 0o600) - import_command = ["gpg", "--batch", "--import", str(key_file_path)] + import_command = ["gpg", "--batch", "--import", "/app/signing-key.gpg"] command = " && ".join( " ".join(shlex.quote(arg) for arg in cmd) for cmd in [import_command, sign_command] @@ -188,10 +190,10 @@ def test_sign_package_error_in_docker(package_command, first_app, mock_gpg): app_context = make_docker_context(package_command, first_app) app_context.run.side_effect = subprocess.CalledProcessError( returncode=1, - cmd=["sh", "-c", "gpg --batch --import /path/to/key && debsigs"], + cmd=["sh", "-c", "gpg --batch --import /app/signing-key.gpg && debsigs"], ) - key_file_path = package_command.data_path / f"{first_app.app_name}-signing-key.gpg" + key_file_path = package_command.bundle_path(first_app) / "signing-key.gpg" key_file_path.touch() with pytest.raises( @@ -214,7 +216,7 @@ def test_sign_package_key_export_error_in_docker(package_command, first_app, moc make_docker_context(package_command, first_app) mock_gpg.export_secret_key.side_effect = BriefcaseCommandError("boom") - key_file_path = package_command.data_path / f"{first_app.app_name}-signing-key.gpg" + key_file_path = package_command.bundle_path(first_app) / "signing-key.gpg" key_file_path.touch() with pytest.raises(BriefcaseCommandError, match=r"boom"): diff --git a/tests/platforms/linux/system/test_package.py b/tests/platforms/linux/system/test_package.py index 83c350e90..c56792fef 100644 --- a/tests/platforms/linux/system/test_package.py +++ b/tests/platforms/linux/system/test_package.py @@ -127,7 +127,7 @@ def test_build_env_abi_failure(package_command, first_app, format): ("arch", "system", "pkg"), # Explicit output format is preserved ("debian", "deb", "deb"), - ("redhat", "rpm", "rpm"), + ("rhel", "rpm", "rpm"), ("arch", "pkg", "pkg"), # This is technically possible, but probably ill-advised ("debian", "rpm", "rpm"), @@ -174,7 +174,7 @@ def test_unknown_packaging_format(package_command, first_app): ("arch", "system", "pkg", ["gnupg"]), # Explicit output format is preserved, and adds the signing tool ("debian", "deb", "deb", ["debsigs"]), - ("redhat", "rpm", "rpm", ["rpm-sign"]), + ("rhel", "rpm", "rpm", ["rpm-sign"]), ("arch", "pkg", "pkg", ["gnupg"]), # On SUSE, rpmsign is provided by rpm-build, which is already installed # by the Docker image; no additional package is needed. From 07a9a25232c0fc92e2e075929604ce11d0500288 Mon Sep 17 00:00:00 2001 From: Siddhant Bayas Date: Sun, 23 Aug 2026 22:15:30 +0530 Subject: [PATCH 09/14] Simplify Docker signing flow Move the Docker-specific handling inside the try/finally block, and key all conditional behavior on the use_docker property. The manual rewriting of paths in the sign command is removed; the Docker layer already rewrites host paths to their container equivalents based on the mount definitions. --- src/briefcase/platforms/linux/system.py | 39 +++++++------------ .../linux/system/signing/test_sign_package.py | 18 +++++---- 2 files changed, 24 insertions(+), 33 deletions(-) diff --git a/src/briefcase/platforms/linux/system.py b/src/briefcase/platforms/linux/system.py index 7c49f02eb..aca0f7fe5 100644 --- a/src/briefcase/platforms/linux/system.py +++ b/src/briefcase/platforms/linux/system.py @@ -1258,27 +1258,19 @@ def sign_package(self, app: LinuxSystemAppConfig, identity: str): subprocess_kwargs: dict[str, Any] = {} key_file_path: Path | None = None - if self.use_docker: - # When packaging inside Docker, the secret key must be made available - # to the container. Export the key to the bundle path (which is mounted - # in to the container), then import it and sign the package in a single - # container run, so the key is not retained in the image or container. - key_file_path = self.bundle_path(app) / "signing-key.gpg" - subprocess_kwargs["mounts"] = [(self.dist_path, "/dist")] - # The bundle folder is mounted at /app, and the dist folder at /dist; - # rewrite the sign command to use the container paths directly. - signature_path = self.signature_path(app) - container_dist_path = f"/dist/{dist_path.name}" - container_signature_path = f"/dist/{signature_path.name}" - sign_command = [ - arg.replace(str(dist_path), container_dist_path).replace( - str(signature_path), container_signature_path - ) - for arg in sign_command - ] try: - if key_file_path is not None: + if self.use_docker: + # When packaging inside Docker, the secret key must be made available + # to the container. Export the key to the bundle path (which is mounted + # in to the container), then import it and sign the package in a single + # container run, so the key is not retained in the image or container. + # + # The bundle and dist folders are mounted in to the container, and the + # Docker layer rewrites the host paths in the commands to their + # container equivalents. + key_file_path = self.bundle_path(app) / "signing-key.gpg" + subprocess_kwargs["mounts"] = [(self.dist_path, "/dist")] self.tools.gnupg.export_secret_key(identity, key_file_path) self.tools.os.chmod(key_file_path, 0o600) sign_command = [ @@ -1287,12 +1279,7 @@ def sign_package(self, app: LinuxSystemAppConfig, identity: str): " && ".join( " ".join(shlex.quote(arg) for arg in command) for command in [ - [ - "gpg", - "--batch", - "--import", - f"/app/{key_file_path.name}", - ], + ["gpg", "--batch", "--import", str(key_file_path)], sign_command, ] ), @@ -1309,7 +1296,7 @@ def sign_package(self, app: LinuxSystemAppConfig, identity: str): f"{app.app_name}." ) from e finally: - if key_file_path is not None: + if self.use_docker: key_file_path.unlink(missing_ok=True) def clean_dist_folder(self, app, **options): diff --git a/tests/platforms/linux/system/signing/test_sign_package.py b/tests/platforms/linux/system/signing/test_sign_package.py index 7113394ac..ed1728dd0 100644 --- a/tests/platforms/linux/system/signing/test_sign_package.py +++ b/tests/platforms/linux/system/signing/test_sign_package.py @@ -109,7 +109,7 @@ def test_sign_package_error(package_command, first_app): "debsigs", "--sign=origin", f"--default-key={JANE}", - "/dist/first-app.deb", + "/path/to/dist/first-app.deb", ], ), ( @@ -119,7 +119,7 @@ def test_sign_package_error(package_command, first_app): "--define", f"_gpg_name {JANE}", "--addsign", - "/dist/first-app.rpm", + "/path/to/dist/first-app.rpm", ], ), ( @@ -130,8 +130,8 @@ def test_sign_package_error(package_command, first_app): "-u", JANE, "--output", - "/dist/first-app.pkg.tar.zst.sig", - "/dist/first-app.pkg.tar.zst", + "/path/to/dist/first-app.pkg.tar.zst.sig", + "/path/to/dist/first-app.pkg.tar.zst", ], ), ], @@ -143,7 +143,11 @@ def test_sign_package_in_docker( format, sign_command, ): - """A package is signed inside a Docker container after importing the signing key.""" + """A package is signed inside a Docker container after importing the signing key. + + The sign command uses host paths; the Docker layer is responsible for rewriting them + to their container equivalents. + """ first_app.packaging_format = format dist_path = Path("/path/to/dist/first-app.pkg.tar.zst") if format == "deb": @@ -165,7 +169,7 @@ def test_sign_package_in_docker( mock_gpg.export_secret_key.assert_called_once_with(JANE, key_file_path) package_command.tools.os.chmod.assert_called_once_with(key_file_path, 0o600) - import_command = ["gpg", "--batch", "--import", "/app/signing-key.gpg"] + import_command = ["gpg", "--batch", "--import", str(key_file_path)] command = " && ".join( " ".join(shlex.quote(arg) for arg in cmd) for cmd in [import_command, sign_command] @@ -190,7 +194,7 @@ def test_sign_package_error_in_docker(package_command, first_app, mock_gpg): app_context = make_docker_context(package_command, first_app) app_context.run.side_effect = subprocess.CalledProcessError( returncode=1, - cmd=["sh", "-c", "gpg --batch --import /app/signing-key.gpg && debsigs"], + cmd=["sh", "-c", "gpg --batch --import signing-key.gpg && debsigs"], ) key_file_path = package_command.bundle_path(first_app) / "signing-key.gpg" From df0236634ad7650601f539e5d340ad820551d5bc Mon Sep 17 00:00:00 2001 From: Siddhant Bayas Date: Sun, 23 Aug 2026 22:15:37 +0530 Subject: [PATCH 10/14] Consolidate signing tool resolution Collapse _docker_signing_tool() into _signing_tool(); the Docker image install step now uses the same method, ignoring the tool and executable names. The SUSE correction (rpmsign is provided by rpm-build, not a separate rpm-sign package) is now applied to local installs as well, so error messages suggest installing the correct package. The single-use _SIGNING_TOOLS constant is folded into _signing_tool(), and resolution of the "system" packaging format moves into app config finalization, so the packaging format is a resolved property of the app by the time any signing logic runs. --- src/briefcase/platforms/linux/system.py | 81 +++++++---------- .../signing/test_verify_signing_tool.py | 22 +++++ .../system/test_mixin__finalize_app_config.py | 87 +++++++++++++++++++ .../linux/system/test_mixin__verify.py | 17 ++-- tests/platforms/linux/system/test_package.py | 72 ++------------- 5 files changed, 157 insertions(+), 122 deletions(-) diff --git a/src/briefcase/platforms/linux/system.py b/src/briefcase/platforms/linux/system.py index aca0f7fe5..c2a75e148 100644 --- a/src/briefcase/platforms/linux/system.py +++ b/src/briefcase/platforms/linux/system.py @@ -43,14 +43,6 @@ parse_freedesktop_os_release, ) -# The tools used to sign a package, keyed by packaging format. Each entry is a -# triple of (tool name, executable name, package name). -_SIGNING_TOOLS = { - "deb": ("debsigs", "debsigs", "debsigs"), - "rpm": ("rpmsign", "rpmsign", "rpm-sign"), - "pkg": ("gpg", "gpg", "gnupg"), -} - # The packaging format implied by each system vendor base. _SYSTEM_PACKAGING_FORMATS = { DEBIAN: "deb", @@ -290,6 +282,18 @@ def finalize_app_config( self.console.verbose(f"Targeting Python{app.python_version_tag}") + # If "system" packaging format was selected, determine what that means. + # This must be done before the app tools are verified, so the Docker + # image can be built with the tools needed to package and sign the app. + if getattr(app, "packaging_format", None) == "system": + app.packaging_format = _SYSTEM_PACKAGING_FORMATS.get(app.target_vendor_base) + if app.packaging_format is None: + raise BriefcaseCommandError( + "Briefcase doesn't know the system packaging format for " + f"{app.target_vendor}. You may be able to build a package " + "by manually specifying a format with -p/--packaging-format" + ) + return LinuxSystemAppConfig(super().finalize_app_config(app, **kwargs)) def _deb_devirtualize(self, package: str) -> str: @@ -413,10 +417,18 @@ def _signing_tool(self, app: LinuxSystemAppConfig) -> tuple[str, str, str]: used to sign the package. :raises KeyError: If the packaging format cannot be determined. """ + # The packaging format may not be set on a draft app config. packaging_format = getattr(app, "packaging_format", None) - if packaging_format == "system": - packaging_format = _SYSTEM_PACKAGING_FORMATS.get(app.target_vendor_base) - return _SIGNING_TOOLS[packaging_format] + tool_name, executable_name, package_name = { + "deb": ("debsigs", "debsigs", "debsigs"), + "rpm": ("rpmsign", "rpmsign", "rpm-sign"), + "pkg": ("gpg", "gpg", "gnupg"), + }[packaging_format] + if packaging_format == "rpm" and app.target_vendor_base == SUSE: + # On SUSE, rpmsign is provided by rpm-build; there is no separate + # `rpm-sign` package. + package_name = "rpm-build" + return tool_name, executable_name, package_name def verify_system_packages(self, app: LinuxSystemAppConfig): """Verify that the required system packages are installed. @@ -757,30 +769,6 @@ def verify_system_python(self): f"({system_version!r})." ) - def _docker_signing_tool(self, app: LinuxSystemAppConfig) -> list[str]: - """Utility method returning the packages needed to install the signing tool in a - Docker image. - - The Docker image is built before the signing identity is selected, so the - signing tool must be installed in the image for the signing step to be able to - run inside the container. - - :param app: The app being packaged - :returns: The list of packages that must be installed in the Docker image to - provide the signing tool. - """ - try: - package_name = self._signing_tool(app)[2] - except KeyError: - # An unknown packaging format has no signing tool that can be identified. - return [] - - if package_name == "rpm-sign" and app.target_vendor_base == SUSE: - # On SUSE, rpmsign is provided by rpm-build, which is already - # installed by the Docker image; there is no `rpm-sign` package. - return [] - return [package_name] - def verify_app_tools(self, app: FinalizedAppConfig): """Verify App environment is prepared and available. @@ -803,9 +791,14 @@ def verify_app_tools(self, app: FinalizedAppConfig): if system_requires is None: system_requires = [] app.system_requires = system_requires - for package in self._docker_signing_tool(app): - if package not in system_requires: - system_requires.append(package) + try: + _, _, package_name = self._signing_tool(app) + except KeyError: + # An unknown packaging format has no signing tool that can be + # identified. + package_name = None + if package_name is not None and package_name not in system_requires: + system_requires.append(package_name) DockerAppContext.verify( tools=self.tools, @@ -1390,18 +1383,6 @@ def _verify_packaging_tools(self, app: LinuxSystemAppConfig): def verify_app_tools(self, app: FinalizedAppConfig): app = cast(LinuxSystemAppConfig, app) - # If "system" packaging format was selected, determine what that means. - # This must be done before the app context is verified, so the Docker - # image can be built with the tools needed to package and sign the app. - if app.packaging_format == "system": - app.packaging_format = _SYSTEM_PACKAGING_FORMATS.get(app.target_vendor_base) - if app.packaging_format is None: - raise BriefcaseCommandError( - "Briefcase doesn't know the system packaging format for " - f"{app.target_vendor}. You may be able to build a package " - "by manually specifying a format with -p/--packaging-format" - ) - super().verify_app_tools(app) if not self.use_docker: diff --git a/tests/platforms/linux/system/signing/test_verify_signing_tool.py b/tests/platforms/linux/system/signing/test_verify_signing_tool.py index 5bd49540b..903a40540 100644 --- a/tests/platforms/linux/system/signing/test_verify_signing_tool.py +++ b/tests/platforms/linux/system/signing/test_verify_signing_tool.py @@ -55,6 +55,28 @@ def test_verify_signing_tool_missing( package_command._verify_signing_tool(first_app) +def test_verify_signing_tool_missing_suse(package_command, first_app): + """On SUSE, the missing tool hint for rpmsign names the rpm-build package.""" + first_app.packaging_format = "rpm" + first_app.target_vendor_base = "suse" + package_command.tools[ + first_app + ].app_context.check_output.side_effect = subprocess.CalledProcessError( + returncode=1, + cmd=["sh", "-c", "command -v rpmsign"], + ) + + with pytest.raises( + BriefcaseCommandError, + match=( + r"Can't find the rpmsign tools. " + r"Try running `sudo zypper install rpm-build`\. " + r"Alternatively, use `--adhoc-sign` to skip signing the package." + ), + ): + package_command._verify_signing_tool(first_app) + + def test_verify_signing_tool_missing_unknown_vendor(package_command, first_app): """If the signing tool isn't installed on an unknown vendor, a generic error is raised.""" 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 6d9cb00ee..10ec80e10 100644 --- a/tests/platforms/linux/system/test_mixin__finalize_app_config.py +++ b/tests/platforms/linux/system/test_mixin__finalize_app_config.py @@ -643,3 +643,90 @@ def test_finalized_attrs(create_command, first_app_config): assert finalized_config.debugger is debugger assert finalized_config.debugger_host == "some-host" assert finalized_config.debugger_port == 8765 + + +@pytest.mark.parametrize( + ("os_release", "input_format", "output_format"), + [ + # System packaging maps to the format implied by the vendor base + ( + "ID=somevendor\nVERSION_CODENAME=surprising\nID_LIKE=debian\n", + "system", + "deb", + ), + ( + "ID=fedora\nVERSION_CODENAME=\nID_LIKE=rhel\n", + "system", + "rpm", + ), + ( + "ID=somevendor\nVERSION_CODENAME=surprising\nID_LIKE=suse\n", + "system", + "rpm", + ), + ( + "ID=cachyos\nVERSION_ID=20230625.0.160368\n", + "system", + "pkg", + ), + # An explicit packaging format is preserved, even if it doesn't match + # the vendor base + ( + "ID=somevendor\nVERSION_CODENAME=surprising\nID_LIKE=debian\n", + "rpm", + "rpm", + ), + ], +) +def test_packaging_format_resolution( + create_command, + first_app_config, + tmp_path, + os_release, + input_format, + output_format, +): + """If "system" packaging format was selected, it is resolved to the format implied + by the vendor base; explicit formats are preserved.""" + create_command.target_image = None + create_command.target_glibc_version = MagicMock(return_value="2.42") + + create_command.tools.platform.freedesktop_os_release = MagicMock( + return_value=parse_freedesktop_os_release(os_release) + ) + + first_app_config.packaging_format = input_format + + finalized_config = create_command.finalize_app_config(first_app_config) + + assert finalized_config.packaging_format == output_format + + +def test_packaging_format_resolution_unknown_vendor( + create_command, + first_app_config, + tmp_path, +): + """If the vendor base can't be determined, an unknown "system" packaging format + raises an error.""" + create_command.target_image = None + create_command.target_glibc_version = MagicMock(return_value="2.42") + + create_command.tools.platform.freedesktop_os_release = MagicMock( + return_value=parse_freedesktop_os_release( + dedent( + """\ + ID=somevendor + VERSION_CODENAME=surprising + """ + ) + ) + ) + + first_app_config.packaging_format = "system" + + with pytest.raises( + BriefcaseCommandError, + match=r"Briefcase doesn't know the system packaging format for somevendor.", + ): + create_command.finalize_app_config(first_app_config) diff --git a/tests/platforms/linux/system/test_mixin__verify.py b/tests/platforms/linux/system/test_mixin__verify.py index 935d38762..15c825b6e 100644 --- a/tests/platforms/linux/system/test_mixin__verify.py +++ b/tests/platforms/linux/system/test_mixin__verify.py @@ -122,16 +122,15 @@ def test_linux_docker(create_command, first_app_config, tmp_path, monkeypatch): ("vendor_base", "packaging_format", "expected_requires"), [ # The signing tool is added to the image requirements for a known format - ("debian", "system", ["debsigs"]), - ("rhel", "system", ["rpm-sign"]), - ("arch", "system", ["gnupg"]), - # On SUSE, rpmsign is provided by rpm-build, which is already installed - # by the Docker image; no additional package is needed. - ("suse", "system", []), - # An unknown vendor resolves to no packaging format; no signing tool - ("basevendor", "system", []), - # A concrete packaging format is used as-is ("debian", "deb", ["debsigs"]), + ("rhel", "rpm", ["rpm-sign"]), + ("arch", "pkg", ["gnupg"]), + # On SUSE, rpmsign is provided by rpm-build; there is no `rpm-sign` + # package + ("suse", "rpm", ["rpm-build"]), + # An unresolved "system" packaging format has no signing tool; format + # resolution happens during app config finalization. + ("basevendor", "system", []), ], ) def test_linux_docker_adds_signing_tool( diff --git a/tests/platforms/linux/system/test_package.py b/tests/platforms/linux/system/test_package.py index 570bebd11..5f2c350e6 100644 --- a/tests/platforms/linux/system/test_package.py +++ b/tests/platforms/linux/system/test_package.py @@ -119,79 +119,26 @@ def test_build_env_abi_failure(package_command, first_app, format): @pytest.mark.parametrize( - ("base_vendor", "input_format", "output_format"), + ("base_vendor", "packaging_format", "expected_requires"), [ - # System packaging maps to known formats - ("debian", "system", "deb"), - ("rhel", "system", "rpm"), - ("arch", "system", "pkg"), - # Explicit output format is preserved - ("debian", "deb", "deb"), - ("rhel", "rpm", "rpm"), - ("arch", "pkg", "pkg"), - # This is technically possible, but probably ill-advised - ("debian", "rpm", "rpm"), - # Unknown base vendor, but explicit packaging format - (None, "deb", "deb"), - (None, "rpm", "rpm"), - (None, "pkg", "pkg"), - ], -) -def test_adjust_packaging_format( - package_command, - first_app, - base_vendor, - input_format, - output_format, -): - """The packaging format can be adjusted based on host system knowledge.""" - first_app.target_vendor_base = base_vendor - first_app.packaging_format = input_format - - package_command.verify_app_tools(first_app) - - assert first_app.packaging_format == output_format - - -def test_unknown_packaging_format(package_command, first_app): - """An unknown packaging format raises an error.""" - first_app.target_vendor_base = None - first_app.packaging_format = "system" - - with pytest.raises( - BriefcaseCommandError, - match=r"Briefcase doesn't know the system packaging format for somevendor.", - ): - package_command.verify_app_tools(first_app) - - -@pytest.mark.parametrize( - ("base_vendor", "input_format", "output_format", "expected_requires"), - [ - # System packaging maps to known formats, and adds the signing tool - ("debian", "system", "deb", ["debsigs"]), - ("rhel", "system", "rpm", ["rpm-sign"]), - ("arch", "system", "pkg", ["gnupg"]), - # Explicit output format is preserved, and adds the signing tool - ("debian", "deb", "deb", ["debsigs"]), - ("rhel", "rpm", "rpm", ["rpm-sign"]), - ("arch", "pkg", "pkg", ["gnupg"]), - # On SUSE, rpmsign is provided by rpm-build, which is already installed - # by the Docker image; no additional package is needed. - ("suse", "system", "rpm", []), + # Known formats add the signing tool for that format + ("debian", "deb", ["debsigs"]), + ("rhel", "rpm", ["rpm-sign"]), + ("arch", "pkg", ["gnupg"]), + # On SUSE, rpmsign is provided by rpm-build; there is no `rpm-sign` package + ("suse", "rpm", ["rpm-build"]), ], ) def test_docker_packaging_format_adjusts_signing_tools( package_command, first_app, base_vendor, - input_format, - output_format, + packaging_format, expected_requires, ): """When using Docker, the signing tool is added to the image requirements.""" first_app.target_vendor_base = base_vendor - first_app.packaging_format = input_format + first_app.packaging_format = packaging_format package_command.target_image = "somevendor:surprising" package_command.extra_docker_build_args = [] package_command.verify_docker_python = mock.MagicMock() @@ -199,7 +146,6 @@ def test_docker_packaging_format_adjusts_signing_tools( package_command.verify_app_tools(first_app) - assert first_app.packaging_format == output_format assert getattr(first_app, "system_requires", []) == expected_requires From f9ce71fc8548cbde9f5f0641fda359382fb5b4cf Mon Sep 17 00:00:00 2001 From: Siddhant Bayas Date: Sun, 23 Aug 2026 23:35:17 +0530 Subject: [PATCH 11/14] Fix cross-platform path handling in Docker signing test The expected sign command is now built from the same Path objects the code under test uses, rather than hard-coded POSIX style strings. On Windows, pathlib normalises paths to backslash separators, which also affects the shell quoting applied to the command, so the expectations must be computed at runtime to match. --- .../linux/system/signing/test_sign_package.py | 74 +++++++++---------- 1 file changed, 33 insertions(+), 41 deletions(-) diff --git a/tests/platforms/linux/system/signing/test_sign_package.py b/tests/platforms/linux/system/signing/test_sign_package.py index ed1728dd0..7c54e1476 100644 --- a/tests/platforms/linux/system/signing/test_sign_package.py +++ b/tests/platforms/linux/system/signing/test_sign_package.py @@ -101,39 +101,11 @@ def test_sign_package_error(package_command, first_app): @pytest.mark.parametrize( - ("format", "sign_command"), + ("format", "extension"), [ - ( - "deb", - [ - "debsigs", - "--sign=origin", - f"--default-key={JANE}", - "/path/to/dist/first-app.deb", - ], - ), - ( - "rpm", - [ - "rpmsign", - "--define", - f"_gpg_name {JANE}", - "--addsign", - "/path/to/dist/first-app.rpm", - ], - ), - ( - "pkg", - [ - "gpg", - "--detach-sign", - "-u", - JANE, - "--output", - "/path/to/dist/first-app.pkg.tar.zst.sig", - "/path/to/dist/first-app.pkg.tar.zst", - ], - ), + ("deb", "deb"), + ("rpm", "rpm"), + ("pkg", "pkg.tar.zst"), ], ) def test_sign_package_in_docker( @@ -141,7 +113,7 @@ def test_sign_package_in_docker( first_app, mock_gpg, format, - sign_command, + extension, ): """A package is signed inside a Docker container after importing the signing key. @@ -149,16 +121,36 @@ def test_sign_package_in_docker( to their container equivalents. """ first_app.packaging_format = format - dist_path = Path("/path/to/dist/first-app.pkg.tar.zst") + dist_path = Path("/path/to/dist") / f"first-app.{extension}" + package_command.distribution_path = mock.MagicMock(return_value=dist_path) if format == "deb": - dist_path = Path("/path/to/dist/first-app.deb") + sign_command = [ + "debsigs", + "--sign=origin", + f"--default-key={JANE}", + str(dist_path), + ] elif format == "rpm": - dist_path = Path("/path/to/dist/first-app.rpm") - package_command.distribution_path = mock.MagicMock(return_value=dist_path) - if format == "pkg": - package_command.signature_path = mock.MagicMock( - return_value=Path("/path/to/dist/first-app.pkg.tar.zst.sig") - ) + sign_command = [ + "rpmsign", + "--define", + f"_gpg_name {JANE}", + "--addsign", + str(dist_path), + ] + else: + signature_path = Path(f"{dist_path}.sig") + package_command.signature_path = mock.MagicMock(return_value=signature_path) + sign_command = [ + "gpg", + "--detach-sign", + "-u", + JANE, + "--output", + str(signature_path), + str(dist_path), + ] + make_docker_context(package_command, first_app) key_file_path = package_command.bundle_path(first_app) / "signing-key.gpg" From 5810c02d4edd2dd8fafa71625cd7a0059e661b84 Mon Sep 17 00:00:00 2001 From: Siddhant Bayas Date: Mon, 24 Aug 2026 00:27:31 +0530 Subject: [PATCH 12/14] Resolve the system packaging format alias after finalization The package and publish commands annotate the CLI-selected packaging format onto the app after app finalization has occurred. This overwrote the concrete packaging format determined during finalization with the raw "system" alias, causing packaging tool verification to fail. The package and publish commands for Linux system backends now resolve the "system" alias to the format determined during finalization before invoking the base command behavior. --- changes/2396.feature.md | 2 +- src/briefcase/platforms/linux/system.py | 69 +++++++++++++++ tests/platforms/linux/system/test_package.py | 85 +++++++++++++++++- tests/platforms/linux/system/test_publish.py | 92 ++++++++++++++++++++ 4 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 tests/platforms/linux/system/test_publish.py diff --git a/changes/2396.feature.md b/changes/2396.feature.md index 3ea1c7668..8487acf9a 100644 --- a/changes/2396.feature.md +++ b/changes/2396.feature.md @@ -1 +1 @@ -Linux system packages (`.deb`, `.rpm` and `.pkg.tar.zst`) can now be signed with a GPG signing identity, using the `--identity` option to `briefcase package`. +Linux system packages (`.deb`, `.rpm` and `.pkg.tar.zst`) can now be signed with a GPG signing identity, using the `--identity` option to `briefcase package`. This includes packages built inside a Docker container. diff --git a/src/briefcase/platforms/linux/system.py b/src/briefcase/platforms/linux/system.py index c2a75e148..d354dcba5 100644 --- a/src/briefcase/platforms/linux/system.py +++ b/src/briefcase/platforms/linux/system.py @@ -9,6 +9,7 @@ from pathlib import Path from typing import Any, cast +from briefcase.channels.base import BasePublicationChannel from briefcase.commands import ( BuildCommand, CreateCommand, @@ -52,6 +53,25 @@ } +def _resolve_system_packaging_format( + app: AppConfig | FinalizedAppConfig, + packaging_format: str, +) -> str: + """Resolve the "system" packaging format alias to a concrete format. + + When the user hasn't explicitly selected a packaging format, the "system" alias is + used. The concrete format implied by the app's target is determined during app + finalization; this returns that value. + + :param app: The app configuration + :param packaging_format: The packaging format requested on the command line + :returns: The concrete packaging format to use + """ + if packaging_format == "system": + return getattr(app, "packaging_format", "system") + return packaging_format + + class LinuxSystemAppConfig(FinalizedAppConfig): """A FinalizedAppConfig with Linux system packaging attributes. @@ -1361,6 +1381,29 @@ class LinuxSystemPackageCommand( def packaging_formats(self): return ["deb", "rpm", "pkg", "system"] + def _package_app( + self, + app: FinalizedAppConfig, + update: bool, + packaging_format: str, + **options, + ) -> dict | None: + """Internal method to invoke packaging on a single app. + + If the user hasn't specified a concrete packaging format, the format determined + during app finalization is used, rather than the raw "system" alias. + + :param app: The application to package + :param update: Should the application be updated (and rebuilt) first? + :param packaging_format: The format of the packaging artefact to create. + """ + return super()._package_app( + app, + update, + _resolve_system_packaging_format(app, packaging_format), + **options, + ) + def _verify_packaging_tools(self, app: LinuxSystemAppConfig): """Verify that the local environment contains the packaging tools.""" tool_name, executable_name, package_name = { @@ -1758,6 +1801,32 @@ def _package_pkg( class LinuxSystemPublishCommand(LinuxSystemDockerMixin, PublishCommand): description = "Publish a Linux system project." + def _publish_app( + self, + app: FinalizedAppConfig, + update: bool, + packaging_format: str, + channel: BasePublicationChannel, + **options, + ) -> dict | None: + """Internal method to publish a single app. + + If the user hasn't specified a concrete packaging format, the format determined + during app finalization is used, rather than the raw "system" alias. + + :param app: The application to publish + :param update: Should the application be updated (and rebuilt) first? + :param packaging_format: The format of the packaging artefact to create. + :param channel: The resolved BasePublicationChannel instance + """ + return super()._publish_app( + app, + update, + _resolve_system_packaging_format(app, packaging_format), + channel, + **options, + ) + # Declare the briefcase command bindings create = LinuxSystemCreateCommand diff --git a/tests/platforms/linux/system/test_package.py b/tests/platforms/linux/system/test_package.py index 5f2c350e6..76fa831ba 100644 --- a/tests/platforms/linux/system/test_package.py +++ b/tests/platforms/linux/system/test_package.py @@ -3,9 +3,13 @@ import pytest +from briefcase.config import AppConfig from briefcase.exceptions import BriefcaseCommandError from briefcase.integrations.subprocess import Subprocess -from briefcase.platforms.linux.system import LinuxSystemPackageCommand +from briefcase.platforms.linux.system import ( + LinuxSystemPackageCommand, + _resolve_system_packaging_format, +) @pytest.fixture @@ -36,6 +40,85 @@ def test_formats(package_command): assert package_command.packaging_formats == ["deb", "rpm", "pkg", "system"] +@pytest.mark.parametrize( + ("packaging_format", "app_packaging_format", "expected"), + [ + # An explicit format passes through untouched + ("deb", "rpm", "deb"), + ("rpm", None, "rpm"), + ("pkg", "deb", "pkg"), + # The "system" alias resolves to the app's finalized packaging format + ("system", "deb", "deb"), + ("system", "rpm", "rpm"), + ("system", "pkg", "pkg"), + # If the app has no finalized packaging format, the alias is retained + ("system", None, "system"), + ], +) +def test_resolve_system_packaging_format( + packaging_format, + app_packaging_format, + expected, +): + """The "system" packaging format alias is resolved to a concrete format.""" + kwargs = {} + if app_packaging_format is not None: + kwargs["packaging_format"] = app_packaging_format + app = AppConfig( + app_name="first", + formal_name="First App", + bundle="com.example", + version="0.0.1", + description="The first simple app", + license={"file": "LICENSE"}, + sources=["src/first"], + **kwargs, + ) + + assert _resolve_system_packaging_format(app, packaging_format) == expected + + +@pytest.mark.parametrize( + ("packaging_format", "expected"), + [ + # The "system" alias uses the finalized packaging format + ("system", "rpm"), + # An explicit format is passed through and annotated onto the app + ("deb", "deb"), + ], +) +def test_package_app_packaging_format( + package_command, + first_app, + packaging_format, + expected, + tmp_path, +): + """The packaging format requested on the command line is resolved before use.""" + # The app has been finalized with a concrete packaging format. + first_app.packaging_format = "rpm" + + # Take the resume path to avoid needing build artifacts, and mock out the + # actual packaging step. + package_command.can_resume = mock.MagicMock(return_value=True) + package_command.verify_resume_app = mock.MagicMock() + package_command.package_app = mock.MagicMock() + package_command.distribution_path = mock.MagicMock( + return_value=tmp_path / "base_path" / "dist" / f"first-app.{expected}" + ) + + package_command._package_app( + first_app, + update=False, + packaging_format=packaging_format, + ) + + # The concrete packaging format was annotated onto the app, and the + # packaging step was invoked. + assert first_app.packaging_format == expected + package_command.package_app.assert_called_once_with(first_app) + + @pytest.mark.parametrize( ("format", "vendor", "codename", "revision", "filename"), [ diff --git a/tests/platforms/linux/system/test_publish.py b/tests/platforms/linux/system/test_publish.py new file mode 100644 index 000000000..a054929ac --- /dev/null +++ b/tests/platforms/linux/system/test_publish.py @@ -0,0 +1,92 @@ +from unittest import mock + +import pytest + +from briefcase.channels.base import BasePublicationChannel +from briefcase.commands.base import full_options +from briefcase.platforms.linux.system import LinuxSystemPublishCommand + + +class DummyLinuxSystemPublishCommand(LinuxSystemPublishCommand): + """A publish command that tracks the package command invocations.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.actions = [] + + def package_command(self, app, **kwargs): + self.actions.append(("package", app.app_name, kwargs.copy())) + # Remove arguments consumed by the underlying call to package_app() + kwargs.pop("update", None) + kwargs.pop("packaging_format", None) + return full_options({"package_state": app.app_name}, kwargs) + + +@pytest.fixture +def publish_command(mock_tools, dummy_console, first_app, tmp_path): + command = DummyLinuxSystemPublishCommand( + console=dummy_console, + tools=mock_tools, + base_path=tmp_path / "base_path", + data_path=tmp_path / "briefcase", + ) + mock_tools.host_os = "Linux" + + # Run outside docker for these tests. + command.target_image = None + + return command + + +@pytest.mark.parametrize( + ("packaging_format", "expected"), + [ + # The "system" alias uses the finalized packaging format + ("system", "rpm"), + # An explicit format is passed through and annotated onto the app + ("deb", "deb"), + ], +) +def test_publish_app_packaging_format( + publish_command, + first_app, + packaging_format, + expected, + tmp_path, +): + """The packaging format requested on the command line is resolved before use.""" + # The app has been finalized with a concrete packaging format. + first_app.packaging_format = "rpm" + + channel = mock.MagicMock(spec_set=BasePublicationChannel) + channel.publish_app.return_value = {"publish_state": "first-app"} + + # The distribution artefact doesn't exist, so packaging will be triggered. + publish_command.distribution_path = mock.MagicMock( + return_value=tmp_path / "base_path" / "dist" / f"first-app.{expected}" + ) + publish_command.verify_app = mock.MagicMock() + + state = publish_command._publish_app( + first_app, + update=False, + packaging_format=packaging_format, + channel=channel, + ) + + # The concrete packaging format was annotated onto the app, and used when + # triggering the package command. + assert first_app.packaging_format == expected + assert publish_command.actions == [ + ("package", "first-app", {"update": False, "packaging_format": expected}) + ] + + # The app was published to the requested channel. + channel.publish_app.assert_called_once_with( + first_app, + command=publish_command, + package_state="first-app", + ) + + assert state == {"publish_state": "first-app"} From 39eae63aba3ba376efbbb9d879b9cf7e5eef7ee5 Mon Sep 17 00:00:00 2001 From: Siddhant Bayas Date: Mon, 24 Aug 2026 00:45:34 +0530 Subject: [PATCH 13/14] Resolve an absent packaging format during finalization If the app configuration doesn't specify a packaging format, the format implied by the vendor base is now determined during app finalization. Previously, resolution only occurred if "system" was explicitly set, so apps with no configured format kept the raw CLI default until packaging tool verification failed. --- src/briefcase/platforms/linux/system.py | 9 +++--- .../system/test_mixin__finalize_app_config.py | 30 ++++++++++++++++++- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/briefcase/platforms/linux/system.py b/src/briefcase/platforms/linux/system.py index d354dcba5..3afa9a22b 100644 --- a/src/briefcase/platforms/linux/system.py +++ b/src/briefcase/platforms/linux/system.py @@ -302,10 +302,11 @@ def finalize_app_config( self.console.verbose(f"Targeting Python{app.python_version_tag}") - # If "system" packaging format was selected, determine what that means. - # This must be done before the app tools are verified, so the Docker - # image can be built with the tools needed to package and sign the app. - if getattr(app, "packaging_format", None) == "system": + # If no packaging format was selected (or the "system" alias was used), + # determine what that means. This must be done before the app tools are + # verified, so the Docker image can be built with the tools needed to + # package and sign the app. + if getattr(app, "packaging_format", None) in (None, "system"): app.packaging_format = _SYSTEM_PACKAGING_FORMATS.get(app.target_vendor_base) if app.packaging_format is None: raise BriefcaseCommandError( 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 10ec80e10..f7b88722a 100644 --- a/tests/platforms/linux/system/test_mixin__finalize_app_config.py +++ b/tests/platforms/linux/system/test_mixin__finalize_app_config.py @@ -289,12 +289,15 @@ def test_properties_unknown_basevendor(create_command, first_app_config): } # A different vendor and version that will be ignored first_app_config.ubuntu = { - "surprise_1": "YYYY", + "surprise_1": "ZZZZ", "jammy": { "surprise_1": "ZZZZ", }, } + # An explicit packaging format; the vendor base can't be resolved to one + first_app_config.packaging_format = "deb" + finalized_config = create_command.finalize_app_config(first_app_config) # The target's config attributes have been merged into the app @@ -702,6 +705,31 @@ def test_packaging_format_resolution( assert finalized_config.packaging_format == output_format +def test_packaging_format_resolution_absent(create_command, first_app_config, tmp_path): + """If no packaging format is specified, the format implied by the vendor base is + used.""" + create_command.target_image = None + create_command.target_glibc_version = MagicMock(return_value="2.42") + + create_command.tools.platform.freedesktop_os_release = MagicMock( + return_value=parse_freedesktop_os_release( + dedent( + """\ + ID=somevendor + VERSION_CODENAME=surprising + ID_LIKE=debian + """ + ) + ) + ) + + # No packaging format has been specified on the app configuration + + finalized_config = create_command.finalize_app_config(first_app_config) + + assert finalized_config.packaging_format == "deb" + + def test_packaging_format_resolution_unknown_vendor( create_command, first_app_config, From 7c9e1d98f9137c4c986d6efdf38eb9f0daa77973 Mon Sep 17 00:00:00 2001 From: Siddhant Bayas Date: Wed, 26 Aug 2026 16:11:47 +0530 Subject: [PATCH 14/14] Only enforce a known packaging format for Docker builds Finalization now resolves an absent or "system" packaging format when the vendor base is known; native builds on unrecognized distributions retain the unresolved alias, deferring any error until packaging, where a clear "use -p/--packaging-format" message is raised. Docker builds still require a known vendor base at finalization, since the target image must be built with the tools needed to package and sign the app; those commands have no -p option, so finalization raises an error suggesting the packaging_format configuration option. Rather than working around the CLI annotation of packaging formats, the Linux package and publish commands now follow the macOS pattern of declaring no default packaging format; the base commands only annotate the app when a format was explicitly specified. --- src/briefcase/commands/package.py | 7 +- src/briefcase/commands/publish.py | 6 +- src/briefcase/platforms/linux/system.py | 130 ++++++------------ tests/commands/package/test_call.py | 23 ++++ .../system/test_mixin__finalize_app_config.py | 64 ++++++--- tests/platforms/linux/system/test_package.py | 97 +++---------- tests/platforms/linux/system/test_publish.py | 24 +++- 7 files changed, 158 insertions(+), 193 deletions(-) diff --git a/src/briefcase/commands/package.py b/src/briefcase/commands/package.py index 6f5d32ca4..fdf8de5f8 100644 --- a/src/briefcase/commands/package.py +++ b/src/briefcase/commands/package.py @@ -104,8 +104,11 @@ def _package_app( :param packaging_format: The format of the packaging artefact to create. """ # Annotate the packaging format onto the app so that distribution path - # resolution works correctly during the resume check. - app.packaging_format = packaging_format + # resolution works correctly during the resume check. If no packaging + # format was specified, the format determined during app finalization + # is used. + if packaging_format: + app.packaging_format = packaging_format resume = self.can_resume(app, **options) diff --git a/src/briefcase/commands/publish.py b/src/briefcase/commands/publish.py index 014ef2e8a..302e9ad73 100644 --- a/src/briefcase/commands/publish.py +++ b/src/briefcase/commands/publish.py @@ -77,8 +77,10 @@ def _publish_app( """ state = None - # Annotate the packaging format onto the app - app.packaging_format = packaging_format + # Annotate the packaging format onto the app. If no packaging format was + # specified, the format determined during app finalization is used. + if packaging_format: + app.packaging_format = packaging_format if update or not self.distribution_path(app).exists(): state = self.package_command( diff --git a/src/briefcase/platforms/linux/system.py b/src/briefcase/platforms/linux/system.py index 3afa9a22b..cccf97aeb 100644 --- a/src/briefcase/platforms/linux/system.py +++ b/src/briefcase/platforms/linux/system.py @@ -9,7 +9,6 @@ from pathlib import Path from typing import Any, cast -from briefcase.channels.base import BasePublicationChannel from briefcase.commands import ( BuildCommand, CreateCommand, @@ -44,33 +43,6 @@ parse_freedesktop_os_release, ) -# The packaging format implied by each system vendor base. -_SYSTEM_PACKAGING_FORMATS = { - DEBIAN: "deb", - RHEL: "rpm", - ARCH: "pkg", - SUSE: "rpm", -} - - -def _resolve_system_packaging_format( - app: AppConfig | FinalizedAppConfig, - packaging_format: str, -) -> str: - """Resolve the "system" packaging format alias to a concrete format. - - When the user hasn't explicitly selected a packaging format, the "system" alias is - used. The concrete format implied by the app's target is determined during app - finalization; this returns that value. - - :param app: The app configuration - :param packaging_format: The packaging format requested on the command line - :returns: The concrete packaging format to use - """ - if packaging_format == "system": - return getattr(app, "packaging_format", "system") - return packaging_format - class LinuxSystemAppConfig(FinalizedAppConfig): """A FinalizedAppConfig with Linux system packaging attributes. @@ -303,17 +275,29 @@ def finalize_app_config( self.console.verbose(f"Targeting Python{app.python_version_tag}") # If no packaging format was selected (or the "system" alias was used), - # determine what that means. This must be done before the app tools are - # verified, so the Docker image can be built with the tools needed to - # package and sign the app. + # determine the format implied by the vendor base. This must be done + # before the app tools are verified, so the Docker image can be built + # with the tools needed to package and sign the app. if getattr(app, "packaging_format", None) in (None, "system"): - app.packaging_format = _SYSTEM_PACKAGING_FORMATS.get(app.target_vendor_base) + app.packaging_format = { + DEBIAN: "deb", + RHEL: "rpm", + ARCH: "pkg", + SUSE: "rpm", + }.get(app.target_vendor_base) + if app.packaging_format is None: - raise BriefcaseCommandError( - "Briefcase doesn't know the system packaging format for " - f"{app.target_vendor}. You may be able to build a package " - "by manually specifying a format with -p/--packaging-format" - ) + if self.use_docker: + raise BriefcaseCommandError( + "Briefcase doesn't know the system packaging format for " + f"{app.target_vendor}. You may be able to proceed by " + "manually specifying a format with the packaging_format " + "option in the app configuration" + ) + + # Native builds don't require a packaging format until the app + # is packaged; retain the unresolved "system" alias. + app.packaging_format = "system" return LinuxSystemAppConfig(super().finalize_app_config(app, **kwargs)) @@ -1382,36 +1366,25 @@ class LinuxSystemPackageCommand( def packaging_formats(self): return ["deb", "rpm", "pkg", "system"] - def _package_app( - self, - app: FinalizedAppConfig, - update: bool, - packaging_format: str, - **options, - ) -> dict | None: - """Internal method to invoke packaging on a single app. - - If the user hasn't specified a concrete packaging format, the format determined - during app finalization is used, rather than the raw "system" alias. - - :param app: The application to package - :param update: Should the application be updated (and rebuilt) first? - :param packaging_format: The format of the packaging artefact to create. - """ - return super()._package_app( - app, - update, - _resolve_system_packaging_format(app, packaging_format), - **options, - ) + @property + def default_packaging_format(self): + # The app's finalized configuration determines the packaging format. + return None def _verify_packaging_tools(self, app: LinuxSystemAppConfig): """Verify that the local environment contains the packaging tools.""" - tool_name, executable_name, package_name = { - "deb": ("dpkg", "dpkg-deb", "dpkg-dev"), - "rpm": ("rpm-build", "rpmbuild", "rpm-build"), - "pkg": ("makepkg", "makepkg", "pacman"), - }[app.packaging_format] + try: + tool_name, executable_name, package_name = { + "deb": ("dpkg", "dpkg-deb", "dpkg-dev"), + "rpm": ("rpm-build", "rpmbuild", "rpm-build"), + "pkg": ("makepkg", "makepkg", "pacman"), + }[app.packaging_format] + except KeyError as e: + raise BriefcaseCommandError( + "Briefcase doesn't know the system packaging format for " + f"{app.target_vendor}. You may be able to build a package " + "by manually specifying a format with -p/--packaging-format" + ) from e if not self.tools.shutil.which(executable_name): if install_cmd := self._system_requirement_tools(app)[3]: @@ -1802,31 +1775,10 @@ def _package_pkg( class LinuxSystemPublishCommand(LinuxSystemDockerMixin, PublishCommand): description = "Publish a Linux system project." - def _publish_app( - self, - app: FinalizedAppConfig, - update: bool, - packaging_format: str, - channel: BasePublicationChannel, - **options, - ) -> dict | None: - """Internal method to publish a single app. - - If the user hasn't specified a concrete packaging format, the format determined - during app finalization is used, rather than the raw "system" alias. - - :param app: The application to publish - :param update: Should the application be updated (and rebuilt) first? - :param packaging_format: The format of the packaging artefact to create. - :param channel: The resolved BasePublicationChannel instance - """ - return super()._publish_app( - app, - update, - _resolve_system_packaging_format(app, packaging_format), - channel, - **options, - ) + @property + def default_packaging_format(self): + # The app's finalized configuration determines the packaging format. + return None # Declare the briefcase command bindings diff --git a/tests/commands/package/test_call.py b/tests/commands/package/test_call.py index 4280182fe..d77e98ab0 100644 --- a/tests/commands/package/test_call.py +++ b/tests/commands/package/test_call.py @@ -1068,3 +1068,26 @@ def test_create_before_package_external_app( # The dist folder has been created. assert (tmp_path / "base_path/dist").exists() + + +def test_package_app_no_packaging_format(package_command, first_app): + """If no packaging format is specified, the finalized packaging format on the app is + retained.""" + # The app has been finalized with a concrete packaging format + first_app.packaging_format = "pkg" + + package_command._package_app(first_app, update=False, packaging_format=None) + + # The packaging format was not modified + assert first_app.packaging_format == "pkg" + + +def test_package_app_explicit_packaging_format(package_command, first_app): + """An explicitly specified packaging format is annotated onto the app.""" + # The app has been finalized with a concrete packaging format + first_app.packaging_format = "pkg" + + package_command._package_app(first_app, update=False, packaging_format="box") + + # The explicit packaging format has been annotated onto the app + assert first_app.packaging_format == "box" 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 f7b88722a..26693138e 100644 --- a/tests/platforms/linux/system/test_mixin__finalize_app_config.py +++ b/tests/platforms/linux/system/test_mixin__finalize_app_config.py @@ -730,31 +730,63 @@ def test_packaging_format_resolution_absent(create_command, first_app_config, tm assert finalized_config.packaging_format == "deb" +@pytest.mark.parametrize( + ("target_image", "expected_format"), + [ + # Docker builds need a concrete format to determine the tools that + # must be installed in the target image + ( + "somevendor:surprising", + None, + ), + # Native builds don't need a packaging format until the app is packaged + ( + None, + "system", + ), + ], +) def test_packaging_format_resolution_unknown_vendor( create_command, first_app_config, tmp_path, + target_image, + expected_format, ): - """If the vendor base can't be determined, an unknown "system" packaging format - raises an error.""" - create_command.target_image = None + """If the vendor base can't be determined, Docker builds raise an error; native + builds retain the unresolved "system" packaging format.""" + create_command.target_image = target_image + if target_image: + create_command.tools.docker = MagicMock() + create_command.tools.docker.check_output.return_value = dedent( + """\ + ID=somevendor + VERSION_CODENAME=surprising + """ + ) create_command.target_glibc_version = MagicMock(return_value="2.42") - create_command.tools.platform.freedesktop_os_release = MagicMock( - return_value=parse_freedesktop_os_release( - dedent( - """\ - ID=somevendor - VERSION_CODENAME=surprising - """ + if not target_image: + create_command.tools.platform.freedesktop_os_release = MagicMock( + return_value=parse_freedesktop_os_release( + dedent( + """\ + ID=somevendor + VERSION_CODENAME=surprising + """ + ) ) ) - ) first_app_config.packaging_format = "system" - with pytest.raises( - BriefcaseCommandError, - match=r"Briefcase doesn't know the system packaging format for somevendor.", - ): - create_command.finalize_app_config(first_app_config) + if expected_format is None: + with pytest.raises( + BriefcaseCommandError, + match=r"Briefcase doesn't know the system packaging format for somevendor.", + ): + create_command.finalize_app_config(first_app_config) + else: + finalized_config = create_command.finalize_app_config(first_app_config) + + assert finalized_config.packaging_format == "system" diff --git a/tests/platforms/linux/system/test_package.py b/tests/platforms/linux/system/test_package.py index 76fa831ba..0cf492f87 100644 --- a/tests/platforms/linux/system/test_package.py +++ b/tests/platforms/linux/system/test_package.py @@ -3,13 +3,9 @@ import pytest -from briefcase.config import AppConfig from briefcase.exceptions import BriefcaseCommandError from briefcase.integrations.subprocess import Subprocess -from briefcase.platforms.linux.system import ( - LinuxSystemPackageCommand, - _resolve_system_packaging_format, -) +from briefcase.platforms.linux.system import LinuxSystemPackageCommand @pytest.fixture @@ -40,83 +36,28 @@ def test_formats(package_command): assert package_command.packaging_formats == ["deb", "rpm", "pkg", "system"] -@pytest.mark.parametrize( - ("packaging_format", "app_packaging_format", "expected"), - [ - # An explicit format passes through untouched - ("deb", "rpm", "deb"), - ("rpm", None, "rpm"), - ("pkg", "deb", "pkg"), - # The "system" alias resolves to the app's finalized packaging format - ("system", "deb", "deb"), - ("system", "rpm", "rpm"), - ("system", "pkg", "pkg"), - # If the app has no finalized packaging format, the alias is retained - ("system", None, "system"), - ], -) -def test_resolve_system_packaging_format( - packaging_format, - app_packaging_format, - expected, -): - """The "system" packaging format alias is resolved to a concrete format.""" - kwargs = {} - if app_packaging_format is not None: - kwargs["packaging_format"] = app_packaging_format - app = AppConfig( - app_name="first", - formal_name="First App", - bundle="com.example", - version="0.0.1", - description="The first simple app", - license={"file": "LICENSE"}, - sources=["src/first"], - **kwargs, - ) +def test_default_format(package_command): + """No default packaging format is defined; the app configuration determines the + format.""" + assert package_command.default_packaging_format is None - assert _resolve_system_packaging_format(app, packaging_format) == expected +def test_verify_packaging_tools_unknown_format(package_command, first_app): + """An unresolved packaging format raises an error naming the vendor.""" + # Restore the real implementation of _verify_packaging_tools + del package_command._verify_packaging_tools -@pytest.mark.parametrize( - ("packaging_format", "expected"), - [ - # The "system" alias uses the finalized packaging format - ("system", "rpm"), - # An explicit format is passed through and annotated onto the app - ("deb", "deb"), - ], -) -def test_package_app_packaging_format( - package_command, - first_app, - packaging_format, - expected, - tmp_path, -): - """The packaging format requested on the command line is resolved before use.""" - # The app has been finalized with a concrete packaging format. - first_app.packaging_format = "rpm" + first_app.packaging_format = "system" - # Take the resume path to avoid needing build artifacts, and mock out the - # actual packaging step. - package_command.can_resume = mock.MagicMock(return_value=True) - package_command.verify_resume_app = mock.MagicMock() - package_command.package_app = mock.MagicMock() - package_command.distribution_path = mock.MagicMock( - return_value=tmp_path / "base_path" / "dist" / f"first-app.{expected}" - ) - - package_command._package_app( - first_app, - update=False, - packaging_format=packaging_format, - ) - - # The concrete packaging format was annotated onto the app, and the - # packaging step was invoked. - assert first_app.packaging_format == expected - package_command.package_app.assert_called_once_with(first_app) + with pytest.raises( + BriefcaseCommandError, + match=( + r"Briefcase doesn't know the system packaging format for somevendor. " + r"You may be able to build a package by manually specifying a format " + r"with -p/--packaging-format" + ), + ): + package_command._verify_packaging_tools(first_app) @pytest.mark.parametrize( diff --git a/tests/platforms/linux/system/test_publish.py b/tests/platforms/linux/system/test_publish.py index a054929ac..314fd6574 100644 --- a/tests/platforms/linux/system/test_publish.py +++ b/tests/platforms/linux/system/test_publish.py @@ -39,12 +39,19 @@ def publish_command(mock_tools, dummy_console, first_app, tmp_path): return command +def test_default_format(publish_command): + """No default packaging format is defined; the app configuration determines the + format.""" + assert publish_command.default_packaging_format is None + + @pytest.mark.parametrize( ("packaging_format", "expected"), [ - # The "system" alias uses the finalized packaging format - ("system", "rpm"), - # An explicit format is passed through and annotated onto the app + # If no packaging format is specified, the finalized packaging format on + # the app is retained + (None, "rpm"), + # An explicit format is annotated onto the app ("deb", "deb"), ], ) @@ -55,7 +62,8 @@ def test_publish_app_packaging_format( expected, tmp_path, ): - """The packaging format requested on the command line is resolved before use.""" + """The packaging format requested on the command line is used; if none is given, the + app's finalized packaging format is preserved.""" # The app has been finalized with a concrete packaging format. first_app.packaging_format = "rpm" @@ -75,11 +83,15 @@ def test_publish_app_packaging_format( channel=channel, ) - # The concrete packaging format was annotated onto the app, and used when + # The expected packaging format was annotated onto the app, and used when # triggering the package command. assert first_app.packaging_format == expected assert publish_command.actions == [ - ("package", "first-app", {"update": False, "packaging_format": expected}) + ( + "package", + "first-app", + {"update": False, "packaging_format": packaging_format}, + ) ] # The app was published to the requested channel.