Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
8f0473b
feat: add support for exporting a GPG secret key
siddhant-bayas Aug 6, 2026
5928c59
feat: install the signing tool in the Linux system Docker image
siddhant-bayas Aug 6, 2026
7e223a6
feat: sign Linux system packages inside the Docker container
siddhant-bayas Aug 6, 2026
2d48aee
feat: suggest --adhoc-sign when the signing tool is missing
siddhant-bayas Aug 6, 2026
054b2f7
docs: document signing Linux system packages built with Docker
siddhant-bayas Aug 6, 2026
9c7f3ca
docs: fix docstring formatting in tests
siddhant-bayas Aug 7, 2026
041d388
Merge branch 'beeware:main' into feat/docker-system-signing
siddhant-bayas Aug 10, 2026
fd313be
docs: add subkey to spelling wordlist
siddhant-bayas Aug 10, 2026
ba9a6a5
Merge branch 'beeware:main' into feat/docker-system-signing
siddhant-bayas Aug 11, 2026
563ca87
Rework Docker signing for Linux system packages
siddhant-bayas Aug 12, 2026
e7e1658
Merge branch 'beeware:main' into feat/docker-system-signing
siddhant-bayas Aug 23, 2026
07a9a25
Simplify Docker signing flow
siddhant-bayas Aug 23, 2026
df02366
Consolidate signing tool resolution
siddhant-bayas Aug 23, 2026
f9ce71f
Fix cross-platform path handling in Docker signing test
siddhant-bayas Aug 23, 2026
5810c02
Resolve the system packaging format alias after finalization
siddhant-bayas Aug 23, 2026
39eae63
Resolve an absent packaging format during finalization
siddhant-bayas Aug 23, 2026
7c9e1d9
Only enforce a known packaging format for Docker builds
siddhant-bayas Aug 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion changes/2396.feature.md
Original file line number Diff line number Diff line change
@@ -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`. This includes packages built inside a Docker container.
8 changes: 7 additions & 1 deletion docs/en/how-to/code-signing/linux.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -64,4 +66,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 sub-key) that does not require a passphrase, or build the package without the `--target` option and sign it natively.
2 changes: 1 addition & 1 deletion docs/en/reference/platforms/linux/system.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 5 additions & 2 deletions src/briefcase/commands/package.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
6 changes: 4 additions & 2 deletions src/briefcase/commands/publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
27 changes: 27 additions & 0 deletions src/briefcase/integrations/gnupg.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
169 changes: 126 additions & 43 deletions src/briefcase/platforms/linux/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -273,6 +274,31 @@ 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 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 = {
DEBIAN: "deb",
RHEL: "rpm",
ARCH: "pkg",
SUSE: "rpm",
}.get(app.target_vendor_base)

if app.packaging_format is None:
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))

def _deb_devirtualize(self, package: str) -> str:
Expand Down Expand Up @@ -388,6 +414,27 @@ 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.
"""
# The packaging format may not be set on a draft app config.
packaging_format = getattr(app, "packaging_format", None)
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.

Expand Down Expand Up @@ -742,6 +789,22 @@ 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
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,
app=app,
Expand Down Expand Up @@ -1057,19 +1120,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.

Expand All @@ -1090,12 +1140,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:
Expand Down Expand Up @@ -1202,13 +1254,48 @@ def sign_package(self, app: LinuxSystemAppConfig, identity: str):
],
}[app.packaging_format]

subprocess_kwargs: dict[str, Any] = {}
key_file_path: Path | None = None

try:
self.tools[app].app_context.run(sign_command, check=True)
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)
Comment thread
freakboy3742 marked this conversation as resolved.
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 self.use_docker:
key_file_path.unlink(missing_ok=True)

def clean_dist_folder(self, app, **options):
super().clean_dist_folder(app, **options)
Expand Down Expand Up @@ -1237,12 +1324,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:
Expand Down Expand Up @@ -1285,13 +1366,25 @@ class LinuxSystemPackageCommand(
def packaging_formats(self):
return ["deb", "rpm", "pkg", "system"]

@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]:
Expand All @@ -1308,21 +1401,6 @@ 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.
if app.packaging_format == "system":
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 not self.use_docker:
self._verify_packaging_tools(app)
Expand Down Expand Up @@ -1697,6 +1775,11 @@ def _package_pkg(
class LinuxSystemPublishCommand(LinuxSystemDockerMixin, PublishCommand):
description = "Publish a Linux system project."

@property
def default_packaging_format(self):
# The app's finalized configuration determines the packaging format.
return None


# Declare the briefcase command bindings
create = LinuxSystemCreateCommand
Expand Down
23 changes: 23 additions & 0 deletions tests/commands/package/test_call.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading
Loading