Skip to content
Open
Show file tree
Hide file tree
Changes from 16 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
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
207 changes: 169 additions & 38 deletions src/briefcase/platforms/linux/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@

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.channels.base import BasePublicationChannel
from briefcase.commands import (
BuildCommand,
CreateCommand,
Expand Down Expand Up @@ -42,6 +44,33 @@
parse_freedesktop_os_release,
)

# The packaging format implied by each system vendor base.
_SYSTEM_PACKAGING_FORMATS = {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As flagged in my previous review - this is an example of a constant that is used in one location. There's no reason to have it pulled out here.

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.
Expand Down Expand Up @@ -273,6 +302,19 @@ 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.
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(
"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"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok - this has exposed an interesting gap.

finalize_app_config is run on every command - including create and run. Those commands don't expose a -p option, so this help command is, at the very least, misleading.

It also means that it's no longer possible to even create a Linux app for an unidentified Linux distribution without modifying the app configuration to add packaging_format.

I think this might also be the cause of the roundabout logic you've added for _package_app - you're essentially adding code to work around the fact that finalise is setting the value for -p, which can then be overridden during packaging. Except that if the user has already had to bake in packaging_format to get past create, passing in any different value with -p will be an error.

So - there's a workflow issue we need to resolve here. At the very least, this needs to be only enforced for Docker builds; not supporting Docker builds for unknown linux distros would also be acceptable.

)

return LinuxSystemAppConfig(super().finalize_app_config(app, **kwargs))

def _deb_devirtualize(self, package: str) -> str:
Expand Down Expand Up @@ -388,6 +430,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 +805,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 +1136,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 +1156,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 +1270,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 +1340,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,6 +1382,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,
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't see why this abstraction is needed. At the point _package_app is invoked the app config will be finalised. That means it has (or should be able to guarantee) a known-good app.packaging_format setting.


def _verify_packaging_tools(self, app: LinuxSystemAppConfig):
"""Verify that the local environment contains the packaging tools."""
tool_name, executable_name, package_name = {
Expand All @@ -1308,21 +1428,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 +1802,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,
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As with package - this seems redundant.



# Declare the briefcase command bindings
create = LinuxSystemCreateCommand
Expand Down
Loading