[Date Prev][Date Next] [Thread Prev][Thread Next] [Date Index] [Thread Index]

Bug#1109088: marked as done (Bump age for debusine/0.11.3?)



Your message dated Fri, 18 Jul 2025 11:30:44 +0200
with message-id <CAM8zJQvR18-6K3anypgjyBF4Kyeus7M3RDeON=yGkkjH+KVw8g@mail.gmail.com>
and subject line Re: Bug#1109088: Bump age for debusine/0.11.3?
has caused the Debian Bug report #1109088,
regarding Bump age for debusine/0.11.3?
to be marked as done.

This means that you claim that the problem has been dealt with.
If this is not the case it is now your responsibility to reopen the
Bug report if necessary, and/or fix the problem forthwith.

(NB: If you are a system administrator and have no idea what this
message is talking about, this may indicate a serious mail system
misconfiguration somewhere. Please contact owner@bugs.debian.org
immediately.)


-- 
1109088: https://bugs.debian.org/cgi-bin/bugreport.cgi?bug=1109088
Debian Bug Tracking System
Contact owner@bugs.debian.org with problems
--- Begin Message ---
Package: release.debian.org
Severity: normal
User: release.debian.org@packages.debian.org
Usertags: unblock
X-Debbugs-Cc: debusine@packages.debian.org
Control: affects -1 + src:debusine

[ Reason ]
debusine 0.11.3 will technically migrate to trixie by itself (at least 
as long as we don't release before that), but I'm wondering if you'd 
consider aging it a bit in order that we can get these changes into 
bookworm-backports sooner.

The "debusine provide-signature --local-file" option is one that (E)LTS 
maintainers using Debusine have asked for because they weren't 
comfortable signing a file downloaded from a remote server, and getting 
the "--server FQDN/SCOPE" change into -backports will allow us to make 
server-side changes that remove confusion for people with a client 
configured with tokens for multiple Debusine instances.

[ Impact ]
Just a delay, unless we're going to release trixie in the next few weeks 
in which case not having these changes in trixie and bookworm-backports 
would be a good bit more inconvenient.

[ Tests ]
Debusine has 100% unit test coverage, run by autopkgtests.

[ Risks ]
The code being changed here is pretty straightforward and readable 
stuff, and these were simple cherry-picks from our development branch.

[ Checklist ]
  [x] all changes are documented in the d/changelog
  [x] I reviewed all changes and I approve them
  [x] attach debdiff against the package in testing

[ Other info ]
There were a few changes to tests and documentation not mentioned in 
debian/changelog, whose purpose was to fix failures in bits of our CI 
that deliberately test against external resources.  "git log" for those 
follows:

commit 565e5cf043430da9f7ad910f10cce7e484750ac4
Author: Colin Watson <cjwatson@debian.org>
Date:   Thu Jul 3 17:27:29 2025 +0100

    Fix test failures with asgiref 3.9.0

    asgiref 3.9.0 raises `CancelledError` when we try to send messages after
    a timeout, while earlier versions cancelled the task but didn't raise an
    exception.  See https://github.com/django/asgiref/issues/518 for more
    details.

commit ce1af7a05fe65f9a82bde1af716aa40585018c98
Author: Colin Watson <cjwatson@debian.org>
Date:   Tue Jul 1 01:02:11 2025 +0100

    Allow reprotest to fail

    It's currently failing as described in https://bugs.debian.org/1108550.

commit 804baff8059568893a9440c0e094094cb14388f3
Author: Colin Watson <cjwatson@debian.org>
Date:   Thu Jun 26 21:56:40 2025 +0100

    Pin lxml < 6.0.0 for now

    Works around #953.

commit 2362812083c9c4099b16b2880c7d796c74fd4716
Author: Carles Pina i Estany <carles@pina.cat>
Date:   Wed Jun 25 14:31:06 2025 +0100

    Fix broken Hetzner link

age-days 7 debusine/0.11.3

Thanks,

-- 
Colin Watson (he/him)                              [cjwatson@debian.org]
diff -Nru debusine-0.11.1/.gitlab-ci.yml debusine-0.11.3/.gitlab-ci.yml
--- debusine-0.11.1/.gitlab-ci.yml	2025-05-04 13:00:19.000000000 +0200
+++ debusine-0.11.3/.gitlab-ci.yml	2025-07-08 16:09:29.000000000 +0200
@@ -172,6 +172,10 @@
   variables:
     SALSA_CI_GBP_BUILDPACKAGE_ARGS: "--git-export=WC"
 
+reprotest:
+  extends: .test-reprotest
+  allow_failure: true
+
 autopkgtest:
   extends: .test-autopkgtest
   parallel:
diff -Nru debusine-0.11.1/debian/changelog debusine-0.11.3/debian/changelog
--- debusine-0.11.1/debian/changelog	2025-05-04 13:00:19.000000000 +0200
+++ debusine-0.11.3/debian/changelog	2025-07-08 16:09:29.000000000 +0200
@@ -1,3 +1,16 @@
+debusine (0.11.3) unstable; urgency=medium
+
+  * client: Allow passing a local copy of the `.changes` file to `debusine
+    provide-signature`.
+
+ -- Colin Watson <cjwatson@debian.org>  Tue, 08 Jul 2025 15:09:29 +0100
+
+debusine (0.11.2) unstable; urgency=medium
+
+  * client: Allow selecting a server using `--server FQDN/SCOPE`.
+
+ -- Colin Watson <cjwatson@debian.org>  Thu, 03 Jul 2025 09:47:02 +0100
+
 debusine (0.11.1) unstable; urgency=medium
 
   * New release.  Highlights:
diff -Nru debusine-0.11.1/debusine/client/cli.py debusine-0.11.3/debusine/client/cli.py
--- debusine-0.11.1/debusine/client/cli.py	2025-05-04 13:00:19.000000000 +0200
+++ debusine-0.11.3/debusine/client/cli.py	2025-07-08 16:09:29.000000000 +0200
@@ -36,6 +36,7 @@
 from debusine.assets import AssetCategory, asset_data_model
 from debusine.client import exceptions
 from debusine.client.client_utils import (
+    copy_file,
     get_debian_package,
     prepare_changes_for_upload,
     prepare_deb_for_upload,
@@ -46,6 +47,7 @@
 from debusine.client.exceptions import DebusineError
 from debusine.client.models import (
     CreateWorkflowRequest,
+    FileResponse,
     RelationType,
     WorkRequestExternalDebsignRequest,
     WorkRequestRequest,
@@ -85,8 +87,8 @@
         parser.add_argument(
             '--server',
             help=(
-                'Set server to be used (use configuration file default '
-                'if not specified)'
+                'Set server to be used, either by section name or as '
+                'FQDN/scope (use configuration file default if not specified)'
             ),
         )
 
@@ -189,6 +191,15 @@
             help="Work request id that needs a signature",
         )
         provide_signature.add_argument(
+            "--local-file",
+            "-l",
+            type=Path,
+            help=(
+                "Path to the .changes file to sign, locally. "
+                "If not specified, it will be downloaded from the server."
+            ),
+        )
+        provide_signature.add_argument(
             "extra_args",
             nargs="*",
             help="Additional arguments passed to debsign",
@@ -411,11 +422,13 @@
     def _build_debusine_object(self) -> Debusine:
         """Return the debusine object matching the command line parameters."""
         configuration = ConfigHandler(
-            server_name=self.args.server,
-            config_file_path=self.args.config_file,
+            server_name=self.args.server, config_file_path=self.args.config_file
         )
 
-        server_configuration = configuration.server_configuration()
+        try:
+            server_configuration = configuration.server_configuration()
+        except ValueError as exc:
+            self._fail(exc)
 
         logging_level = logging.WARNING if self.args.silent else logging.INFO
 
@@ -508,7 +521,10 @@
                 )
             case "provide-signature":
                 self._provide_signature(
-                    debusine, self.args.work_request_id, self.args.extra_args
+                    debusine,
+                    self.args.work_request_id,
+                    self.args.local_file,
+                    self.args.extra_args,
                 )
             case "create-artifact":
                 if self.args.data is not None:
@@ -706,13 +722,39 @@
         with self._api_call_or_fail():
             debusine.work_request_retry(work_request_id)
 
+    def _fetch_local_file(
+        self, src: Path, dest: Path, artifact_file: FileResponse
+    ) -> None:
+        """Copy src to dest and verify that its hash matches artifact_file."""
+        assert src.exists()
+        hashes = copy_file(src, dest, artifact_file.checksums.keys())
+        if hashes["size"] != artifact_file.size:
+            self._fail(
+                f'"{src}" size mismatch (expected {artifact_file.size} bytes)'
+            )
+
+        for hash_name, expected_value in artifact_file.checksums.items():
+            if hashes[hash_name] != expected_value:
+                self._fail(
+                    f'"{src}" hash mismatch (expected {hash_name} '
+                    f'= {expected_value})'
+                )
+
     def _provide_signature_debsign(
         self,
         debusine: Debusine,
         work_request: WorkRequestResponse,
+        local_file: Path | None,
         debsign_args: list[str],
     ) -> None:
         """Provide a work request with an external signature using `debsign`."""
+        if local_file is not None:
+            if local_file.suffix != ".changes":
+                self._fail(
+                    f"--local-file {str(local_file)!r} is not a .changes file."
+                )
+            if not local_file.exists():
+                self._fail(f"--local-file {str(local_file)!r} does not exist.")
         # Get a version of the work request with its dynamic task data
         # resolved.
         with self._api_call_or_fail():
@@ -739,8 +781,15 @@
                     or name.endswith(".dsc")
                     or name.endswith(".buildinfo")
                 ):
-                    with self._api_call_or_fail():
-                        debusine.download_artifact_file(unsigned, name, path)
+                    if local_file:
+                        self._fetch_local_file(
+                            local_file.parent / name, path, file_response
+                        )
+                    else:
+                        with self._api_call_or_fail():
+                            debusine.download_artifact_file(
+                                unsigned, name, path
+                            )
             # Upload artifacts are guaranteed to have exactly one .changes
             # file.
             [changes_path] = [
@@ -773,7 +822,11 @@
                 )
 
     def _provide_signature(
-        self, debusine: Debusine, work_request_id: int, extra_args: list[str]
+        self,
+        debusine: Debusine,
+        work_request_id: int,
+        local_file: Path | None,
+        extra_args: list[str],
     ) -> None:
         """Provide a work request with an external signature."""
         # Find out what kind of work request we're dealing with.
@@ -782,7 +835,7 @@
         match (work_request.task_type, work_request.task_name):
             case "Wait", "externaldebsign":
                 self._provide_signature_debsign(
-                    debusine, work_request, extra_args
+                    debusine, work_request, local_file, extra_args
                 )
             case _:
                 self._fail(
diff -Nru debusine-0.11.1/debusine/client/client_utils.py debusine-0.11.3/debusine/client/client_utils.py
--- debusine-0.11.1/debusine/client/client_utils.py	2025-05-04 13:00:19.000000000 +0200
+++ debusine-0.11.3/debusine/client/client_utils.py	2025-07-08 16:09:29.000000000 +0200
@@ -57,6 +57,39 @@
             )
 
 
+def write_and_hash(
+    stream: Iterable[bytes], destination: Path, hashes: Iterable[str]
+) -> DownloadedFileStats:
+    """Write chunks from stream to dest and hash the contents using hashes."""
+    hashers = {hash_name: hashlib.new(hash_name) for hash_name in hashes}
+    with destination.open("xb") as f:
+        for chunk in stream:
+            f.write(chunk)
+            for hasher in hashers.values():
+                hasher.update(chunk)
+        size = f.tell()
+    stats: dict[str, str | int] = {"size": size}
+    for hash_name, hasher in hashers.items():
+        stats[hash_name] = hasher.hexdigest()
+    return stats
+
+
+def copy_file(
+    source: Path,
+    destination: Path,
+    hashes: Iterable[str] = SOURCE_PACKAGE_HASHES,
+) -> DownloadedFileStats:
+    """
+    Copy source into destination.
+
+    Return all the hashes specified and size, as a dict.
+    """
+    with source.open("rb") as f:
+        return write_and_hash(
+            iter(partial(f.read, 1024 * 1024), b""), destination, hashes
+        )
+
+
 def download_file(
     url: str,
     destination: Path,
@@ -68,21 +101,13 @@
     Return all the hashes specified and size, as a dict.
     """
     log.info("Downloading %s...", url)
-    hashers = {hash_name: hashlib.new(hash_name) for hash_name in hashes}
     with requests.get(url, stream=True) as r:
         r.raise_for_status()
         if "Content-Length" in r.headers:
             log.info("Size: %.2f MiB", int(r.headers["Content-Length"]) / 2**20)
-        with destination.open("xb") as f:
-            for chunk in r.iter_content(chunk_size=1024 * 1024):
-                f.write(chunk)
-                for hasher in hashers.values():
-                    hasher.update(chunk)
-            size = f.tell()
-    stats: dict[str, str | int] = {"size": size}
-    for hash_name, hasher in hashers.items():
-        stats[hash_name] = hasher.hexdigest()
-    return stats
+        return write_and_hash(
+            r.iter_content(chunk_size=1024 * 1024), destination, hashes
+        )
 
 
 def get_url_contents_sha256sum(
diff -Nru debusine-0.11.1/debusine/client/config.py debusine-0.11.3/debusine/client/config.py
--- debusine-0.11.1/debusine/client/config.py	2025-05-04 13:00:19.000000000 +0200
+++ debusine-0.11.3/debusine/client/config.py	2025-07-08 16:09:29.000000000 +0200
@@ -15,6 +15,7 @@
 from configparser import ConfigParser
 from pathlib import Path
 from typing import NoReturn, TextIO
+from urllib.parse import urlparse
 
 
 class ConfigHandler(ConfigParser):
@@ -40,7 +41,9 @@
         """
         Initialize variables and reads the configuration file.
 
-        :param server_name: None for the default server from the configuration
+        :param server_name: look up configuration matching this server name
+          or FQDN/scope (or None for the default server from the
+          configuration)
         :param config_file_path: location of the configuration file
         """
         super().__init__()
@@ -95,14 +98,32 @@
         self, server_name: str
     ) -> MutableMapping[str, str]:
         """Return configuration for server_name or aborts."""
-        section_name = f'server:{server_name}'
+        section_name: str | None
+        if "/" in server_name:
+            # Look up the section by FQDN and scope.
+            server_fqdn, scope_name = server_name.split("/")
+            for section_name in self.sections():
+                if (
+                    section_name.startswith("server:")
+                    and (
+                        (api_url := self[section_name].get("api-url"))
+                        is not None
+                    )
+                    and urlparse(api_url).hostname == server_fqdn
+                    and self[section_name].get("scope") == scope_name
+                ):
+                    break
+            else:
+                section_name = None
+        else:
+            # Look up the section by name.
+            section_name = f'server:{server_name}'
 
-        if section_name not in self:
-            self._fail(
-                f'[{section_name}] section not found '
-                f'in {self._config_file_path} .'
+        if section_name is None or section_name not in self:
+            raise ValueError(
+                f"No Debusine client configuration for {server_name!r}; "
+                f"run 'debusine setup' to configure it"
             )
-
         server_configuration = self[section_name]
 
         self._ensure_server_configuration(server_configuration, section_name)
diff -Nru debusine-0.11.1/debusine/client/dput_ng/dput_ng_utils.py debusine-0.11.3/debusine/client/dput_ng/dput_ng_utils.py
--- debusine-0.11.1/debusine/client/dput_ng/dput_ng_utils.py	2025-05-04 13:00:19.000000000 +0200
+++ debusine-0.11.3/debusine/client/dput_ng/dput_ng_utils.py	2025-07-08 16:09:29.000000000 +0200
@@ -11,7 +11,6 @@
 
 from collections.abc import MutableMapping
 from typing import Any
-from urllib.parse import urlparse
 
 from dput.core import logger
 
@@ -19,25 +18,15 @@
 from debusine.client.debusine import Debusine
 
 
-def get_debusine_client_config(fqdn: str) -> MutableMapping[str, str]:
+def get_debusine_client_config(
+    fqdn: str, scope_name: str | None = None
+) -> MutableMapping[str, str]:
     """
     Get debusine client configuration for a given FQDN.
 
     This is a useful hook for testing.
     """
-    configuration = ConfigHandler()
-    for section in configuration.sections():
-        if (
-            section.startswith("server:")
-            and (api_url := configuration[section].get("api-url")) is not None
-            and urlparse(api_url).hostname == fqdn
-        ):
-            configuration._server_name = section[len("server:") :]
-            break
-    else:
-        raise ValueError(
-            f"No debusine client configuration for {fqdn}; run 'debusine setup'"
-        )
+    configuration = ConfigHandler(server_name=f"{fqdn}/{scope_name}")
     return configuration.server_configuration()
 
 
@@ -46,7 +35,7 @@
     fqdn = profile["fqdn"]
     scope = profile["debusine_scope"]
 
-    config = get_debusine_client_config(fqdn)
+    config = get_debusine_client_config(fqdn, scope_name=scope)
     return Debusine(
         base_api_url=config["api-url"],
         api_token=config["token"],
diff -Nru debusine-0.11.1/debusine/client/dput_ng/tests/test_dput_ng_utils.py debusine-0.11.3/debusine/client/dput_ng/tests/test_dput_ng_utils.py
--- debusine-0.11.1/debusine/client/dput_ng/tests/test_dput_ng_utils.py	2025-05-04 13:00:19.000000000 +0200
+++ debusine-0.11.3/debusine/client/dput_ng/tests/test_dput_ng_utils.py	2025-07-08 16:09:29.000000000 +0200
@@ -30,7 +30,7 @@
                 [server:debusine.example.net]
                 api-url = https://debusine.example.net/api
                 token = some-token
-                scope = default-scope
+                scope = debian
                 """
             )
         )
@@ -69,12 +69,12 @@
                 [server:example]
                 api-url = https://debusine.example.net/api
                 token = some-token
-                scope = debusine
+                scope = debian
 
                 [server:another-example]
                 api-url = https://debusine.another-example.net/api
                 token = some-token
-                scope = debusine
+                scope = debian
                 """
             )
         )
@@ -122,8 +122,9 @@
             ),
             self.assertRaisesRegex(
                 ValueError,
-                r"No debusine client configuration for debusine\.example\.net; "
-                r"run 'debusine setup'",
+                r"No Debusine client configuration for "
+                r"'debusine\.example\.net/debian'; "
+                r"run 'debusine setup' to configure it",
             ),
         ):
             make_debusine_client(profile)
diff -Nru debusine-0.11.1/debusine/client/tests/test_cli.py debusine-0.11.3/debusine/client/tests/test_cli.py
--- debusine-0.11.1/debusine/client/tests/test_cli.py	2025-05-04 13:00:19.000000000 +0200
+++ debusine-0.11.3/debusine/client/tests/test_cli.py	2025-07-08 16:09:29.000000000 +0200
@@ -289,6 +289,46 @@
         debusine = cli._build_debusine_object()
         self.assertEqual(debusine.scope, "altscope")
 
+    def test_no_server_found_by_fqdn_and_scope(self) -> None:
+        """Cli fails if no matching server is found by FQDN/scope."""
+        cli = self.create_cli(
+            [
+                "--server",
+                "nonexistent.example.org/scope",
+                "show-work-request",
+                "10",
+            ]
+        )
+        cli._parse_args()
+
+        stderr, stdout = self.capture_output(
+            cli._build_debusine_object, assert_system_exit_code=3
+        )
+
+        self.assertEqual(
+            stderr,
+            "No Debusine client configuration for "
+            "'nonexistent.example.org/scope'; "
+            "run 'debusine setup' to configure it\n",
+        )
+
+    def test_no_server_found_by_name(self) -> None:
+        """Cli fails if no matching server is found by name."""
+        cli = self.create_cli(
+            ["--server", "nonexistent", "show-work-request", "10"]
+        )
+        cli._parse_args()
+
+        stderr, stdout = self.capture_output(
+            cli._build_debusine_object, assert_system_exit_code=3
+        )
+
+        self.assertEqual(
+            stderr,
+            "No Debusine client configuration for 'nonexistent'; "
+            "run 'debusine setup' to configure it\n",
+        )
+
     def test_build_debusine_object_logging_warning(self) -> None:
         """Cli with --silent create and pass logger level WARNING."""
         cli = self.create_cli(["--silent", "show-work-request", "10"])
@@ -2236,8 +2276,12 @@
             yield patcher
 
     @responses.activate
-    def verify_provide_signature_scenario(self, extra_args: list[str]) -> None:
+    def verify_provide_signature_scenario(
+        self, local_changes: bool = False, extra_args: list[str] | None = None
+    ) -> None:
         """Test a provide-signature scenario."""
+        if extra_args is None:
+            extra_args = []
         directory = self.create_temporary_directory()
         (tar := directory / "foo_1.0.tar.xz").write_text("tar")
         (dsc := directory / "foo_1.0.dsc").write_text("dsc")
@@ -2268,6 +2312,8 @@
         )
         remote_signed_artifact = RemoteArtifact(id=3, workspace="Testing")
         args = ["provide-signature", "1"]
+        if local_changes:
+            args.extend(["--local-file", str(changes)])
         if extra_args:
             args.extend(["--", *extra_args])
         cli = self.create_cli(args)
@@ -2300,17 +2346,23 @@
         ):
             stderr, stdout = self.capture_output(cli.execute)
 
-            self.assertRegex(
-                stderr,
-                r"\A"
-                + "\n".join(
-                    [
-                        fr"Artifact file downloaded: .*/{re.escape(path.name)}"
-                        for path in (dsc, buildinfo, changes)
-                    ]
+            if local_changes:
+                self.assertEqual(stderr, "")
+            else:
+                self.assertRegex(
+                    stderr,
+                    r"\A"
+                    + "\n".join(
+                        [
+                            (
+                                fr"Artifact file downloaded: "
+                                fr".*/{re.escape(path.name)}"
+                            )
+                            for path in (dsc, buildinfo, changes)
+                        ]
+                    )
+                    + r"\n\Z",
                 )
-                + r"\n\Z",
-            )
             self.assertEqual(stdout, "")
 
             # Ensure that the CLI called debusine in the right sequence.
@@ -2343,14 +2395,22 @@
                 1,
                 WorkRequestExternalDebsignRequest(signed_artifact=3),
             )
+        if local_changes:
+            for path in (tar, dsc, buildinfo, changes):
+                url = f"https://example.com/{path.name}";
+                responses.assert_call_count(url, 0)
 
     def test_debsign(self) -> None:
         """provide-signature calls debsign and posts the output."""
-        self.verify_provide_signature_scenario([])
+        self.verify_provide_signature_scenario()
+
+    def test_debsign_local_changes(self) -> None:
+        """provide-signature uses local .changes."""
+        self.verify_provide_signature_scenario(local_changes=True)
 
     def test_debsign_with_args(self) -> None:
         """provide-signature calls debsign with args and posts the output."""
-        self.verify_provide_signature_scenario(["-kKEYID"])
+        self.verify_provide_signature_scenario(extra_args=["-kKEYID"])
 
     def test_wrong_work_request(self) -> None:
         """provide-signature only works for Wait/externaldebsign requests."""
@@ -2369,6 +2429,105 @@
             "Don't know how to provide signature for Wait/delay work request\n",
         )
 
+    def verify_provide_signature_error_scenario(
+        self,
+        error_regex: str,
+        local_file: str | None = None,
+        expect_size: dict[str, int] | None = None,
+        expect_sha256: dict[str, str] | None = None,
+    ) -> None:
+        """Test a provide-signature failure scenario with local_file."""
+        if expect_size is None:
+            expect_size = {}
+        if expect_sha256 is None:
+            expect_sha256 = {}
+        directory = self.create_temporary_directory()
+        (tar := directory / "foo_1.0.tar.xz").write_text("tar")
+        (dsc := directory / "foo_1.0.dsc").write_text("dsc")
+        (buildinfo := directory / "foo_1.0_source.buildinfo").write_text(
+            "buildinfo"
+        )
+        changes = directory / "foo_1.0_source.changes"
+        self.write_changes_file(changes, [tar, dsc, buildinfo])
+        bare_work_request_response = create_work_request_response(
+            id=1, task_type="Wait", task_name="externaldebsign"
+        )
+        full_work_request_response = create_work_request_response(
+            id=1,
+            task_type="Wait",
+            task_name="externaldebsign",
+            dynamic_task_data={"unsigned_id": 2},
+        )
+        unsigned_artifact_response = create_artifact_response(
+            id=2,
+            files={
+                path.name: create_file_response(
+                    size=expect_size.get(path.name, path.stat().st_size),
+                    checksums={
+                        "sha256": expect_sha256.get(
+                            path.name, calculate_hash(path, "sha256").hex()
+                        )
+                    },
+                    url=f"https://example.com/{path.name}";,
+                )
+                for path in (tar, dsc, buildinfo, changes)
+            },
+        )
+        if local_file is None:
+            local_file = str(changes)
+        args = ["provide-signature", "1", "--local-file", local_file]
+        cli = self.create_cli(args)
+
+        with (
+            self.patch_debusine_method(
+                "work_request_get", return_value=bare_work_request_response
+            ),
+            self.patch_debusine_method(
+                "work_request_external_debsign_get",
+                return_value=full_work_request_response,
+            ),
+            self.patch_debusine_method(
+                "artifact_get", return_value=unsigned_artifact_response
+            ),
+            mock.patch("subprocess.run"),
+        ):
+            stderr, stdout = self.capture_output(
+                cli.execute, assert_system_exit_code=3
+            )
+
+        self.assertRegex(stderr, error_regex)
+
+    def test_missing_local_changes(self) -> None:
+        """provide-signature raises an error for missing --local-file."""
+        self.verify_provide_signature_error_scenario(
+            r"^--local-file 'nonexistent\.changes' does not exist\.$",
+            local_file="nonexistent.changes",
+        )
+
+    def test_local_dsc(self) -> None:
+        """provide-signature raises an error for the wrong kind of file."""
+        self.verify_provide_signature_error_scenario(
+            r"^--local-file 'foo\.dsc' is not a \.changes file.$",
+            local_file="foo.dsc",
+        )
+
+    def test_size_mismatch(self) -> None:
+        """provide-signature verifies the size of local files."""
+        self.verify_provide_signature_error_scenario(
+            r'^"[^"]+/foo_1\.0\.dsc" size mismatch \(expected 999 bytes\)$',
+            expect_size={"foo_1.0.dsc": 999},
+        )
+
+    def test_hash_mismatch(self) -> None:
+        """provide-signature verifies the hashes of local files."""
+        self.verify_provide_signature_error_scenario(
+            (
+                r'^"[^"]+/foo_1\.0\.dsc" hash mismatch '
+                r'\(expected sha256 = abc123\)$'
+            ),
+            expect_sha256={"foo_1.0.dsc": "abc123"},
+        )
+
 
 class CliCreateWorkflowTemplateTests(BaseCliTests):
     """Tests for the CLI `create-workflow-template` command."""
diff -Nru debusine-0.11.1/debusine/client/tests/test_client_utils.py debusine-0.11.3/debusine/client/tests/test_client_utils.py
--- debusine-0.11.1/debusine/client/tests/test_client_utils.py	2025-05-04 13:00:19.000000000 +0200
+++ debusine-0.11.3/debusine/client/tests/test_client_utils.py	2025-07-08 16:09:29.000000000 +0200
@@ -20,6 +20,7 @@
 
 from debusine.artifacts.playground import ArtifactPlayground
 from debusine.client.client_utils import (
+    copy_file,
     dget,
     download_file,
     get_debian_package,
@@ -104,6 +105,15 @@
             self.r_mock.get(f"http://example.com/{filename}";, body=body)
         self.set_dsc_response()
         self.set_changes_response()
+        self.expected_stats = {
+            "foo.deb": {
+                "sha256": "6ca13d52ca70c883e0f0bb101e425a89e8624de51db2d239259"
+                "3af6a84118090",
+                "sha1": "6367c48dd193d56ea7b0baad25b19455e529f5ee",
+                "md5": "e99a18c428cb38d5f260853678922e03",
+                "size": 6,
+            },
+        }
 
     def _set_response(
         self,
@@ -170,6 +180,13 @@
             ],
         )
 
+    def test_copy_file_stats(self) -> None:
+        src = self.workdir / "src.deb"
+        dest = self.workdir / "foo.deb"
+        src.write_bytes(self.bodies["foo.deb"])
+        stats = copy_file(src, dest)
+        self.assertEqual(self.expected_stats["foo.deb"], stats)
+
     def test_download_file_downloads(self) -> None:
         """Check `download_file` writes the expected file."""
         dest = self.workdir / "foo.deb"
@@ -181,16 +198,7 @@
         """Check the return value of `download_file`."""
         dest = self.workdir / "foo.deb"
         stats = download_file("http://example.com/foo.deb";, dest)
-        self.assertEqual(
-            {
-                "sha256": "6ca13d52ca70c883e0f0bb101e425a89e8624de51db2d239259"
-                "3af6a84118090",
-                "sha1": "6367c48dd193d56ea7b0baad25b19455e529f5ee",
-                "md5": "e99a18c428cb38d5f260853678922e03",
-                "size": 6,
-            },
-            stats,
-        )
+        self.assertEqual(self.expected_stats["foo.deb"], stats)
 
     def test_download_file_logging(self) -> None:
         """Ensure `download_file` logs its requests."""
diff -Nru debusine-0.11.1/debusine/client/tests/test_config.py debusine-0.11.3/debusine/client/tests/test_config.py
--- debusine-0.11.1/debusine/client/tests/test_config.py	2025-05-04 13:00:19.000000000 +0200
+++ debusine-0.11.3/debusine/client/tests/test_config.py	2025-07-08 16:09:29.000000000 +0200
@@ -160,19 +160,49 @@
             },
         )
 
+    def test_server_configuration_find_by_fqdn(self) -> None:
+        """ConfigHandler.server_configuration() finds a section by FQDN."""
+        config = self.valid_configuration()
+
+        config_handler = self.build_config_handler(
+            config, server_name="debusine.kali.org/kali"
+        )
+        config_server = config["server:kali"]
+        self.assertEqual(
+            config_handler.server_configuration(),
+            {
+                "api-url": config_server["api-url"],
+                "scope": config_server["scope"],
+                "token": config_server["token"],
+            },
+        )
+
+    def test_server_configuration_find_by_fqdn_wrong_scope(self) -> None:
+        """ConfigHandler.server_configuration() requires FQDN/scope to match."""
+        config = self.valid_configuration()
+        config_handler = self.build_config_handler(
+            config, server_name="debusine.kali.org/not-kali"
+        )
+
+        with self.assertRaisesRegex(
+            ValueError,
+            r"No Debusine client configuration for "
+            r"'debusine\.kali\.org/not-kali'; "
+            r"run 'debusine setup' to configure it",
+        ):
+            config_handler.server_configuration()
+
     def test_server_non_existing_error(self) -> None:
         """ConfigHandler._server_configuration('does-not-exist') aborts."""
         config = self.build_config_handler(self.valid_configuration())
 
-        with self.assertRaisesSystemExit(3):
+        with self.assertRaisesRegex(
+            ValueError,
+            "No Debusine client configuration for 'does-not-exist'; "
+            "run 'debusine setup' to configure it",
+        ):
             config._server_configuration('does-not-exist')
 
-        self.assertEqual(
-            self.stderr.getvalue(),
-            f'[server:does-not-exist] section not found '
-            f'in {config._config_file_path} .\n',
-        )
-
     def test_server_incomplete_configuration_error(self) -> None:
         """ConfigHandler._server_configuration('incomplete-server') aborts."""
         config_parser = ConfigParser()
diff -Nru debusine-0.11.1/debusine/server/tests/test_consumers.py debusine-0.11.3/debusine/server/tests/test_consumers.py
--- debusine-0.11.1/debusine/server/tests/test_consumers.py	2025-05-04 13:00:19.000000000 +0200
+++ debusine-0.11.3/debusine/server/tests/test_consumers.py	2025-07-08 16:09:29.000000000 +0200
@@ -243,7 +243,13 @@
         for message in expected_msgs:
             self.assertIn(message, received_messages)
 
-        await communicator.disconnect()
+        try:
+            await communicator.disconnect()
+        except asyncio.exceptions.CancelledError:  # pragma: no cover
+            # asgiref < 3.9.0 swallowed this exception; asgiref 3.9.0
+            # re-raises it.  See
+            # https://github.com/django/asgiref/issues/518.
+            pass
 
     async def test_connect_valid_token(self) -> None:
         """Connect succeeds and a request for dynamic metadata is received."""
diff -Nru debusine-0.11.1/docs/reference/debusine-cli.rst debusine-0.11.3/docs/reference/debusine-cli.rst
--- debusine-0.11.1/docs/reference/debusine-cli.rst	2025-05-04 13:00:19.000000000 +0200
+++ debusine-0.11.3/docs/reference/debusine-cli.rst	2025-07-08 16:09:29.000000000 +0200
@@ -8,7 +8,7 @@
 It is provided by the ``debusine-client`` package and contains many
 sub-commands.
 
-The command is documented in :ref:`debusine-cli-config`.
+The configuration file is documented in :ref:`debusine-cli-config`.
 
 Output of the ``debusine`` command
 ----------------------------------
@@ -62,6 +62,11 @@
                             Create a workflow template
 	[...]
 
+If you have multiple servers configured, then you may need to select which
+one to use.  You can do this using ``--server FQDN/SCOPE`` (for example,
+``--server debusine.debian.net/debian``), or using ``--server NAME`` (where
+the available names are shown by ``debusine setup``).
+
 Each sub-command is self-documented, use ``debusine sub-command
 --help``:
 
diff -Nru debusine-0.11.1/docs/reference/deployment/worker-pools.rst debusine-0.11.3/docs/reference/deployment/worker-pools.rst
--- debusine-0.11.1/docs/reference/deployment/worker-pools.rst	2025-05-04 13:00:19.000000000 +0200
+++ debusine-0.11.3/docs/reference/deployment/worker-pools.rst	2025-07-08 16:09:29.000000000 +0200
@@ -361,7 +361,7 @@
 ----------------------------------
 
 Many of the fields map closely to parameters to the Hetzner Cloud
-`server creation <https://docs.hetzner.cloud/#servers-create-a-server>`_
+`server creation <https://docs.hetzner.cloud/reference/cloud#servers-create-a-server>`_
 API call:
 
 * ``server_type`` (string):
diff -Nru debusine-0.11.1/docs/reference/release-history.rst debusine-0.11.3/docs/reference/release-history.rst
--- debusine-0.11.1/docs/reference/release-history.rst	2025-05-04 13:00:19.000000000 +0200
+++ debusine-0.11.3/docs/reference/release-history.rst	2025-07-08 16:09:29.000000000 +0200
@@ -6,6 +6,38 @@
 
 .. towncrier release notes start
 
+.. _release-0.11.3:
+
+0.11.3 (2025-07-08)
+-------------------
+
+Client
+~~~~~~
+
+Features
+^^^^^^^^
+
+- A local copy of the ``.changes`` file can be passed to ``provide-signature``
+  for signing and uploading. (`#816
+  <https://salsa.debian.org/freexian-team/debusine/-/issues/816>`__)
+
+
+.. _release-0.11.2:
+
+0.11.2 (2025-07-03)
+-------------------
+
+Client
+~~~~~~
+
+Features
+^^^^^^^^
+
+- Allow selecting a server using ``--server FQDN/SCOPE``, as an alternative to
+  needing to know the ``[server:...]`` section name in the configuration file.
+  (`#749 <https://salsa.debian.org/freexian-team/debusine/-/issues/749>`__)
+
+
 .. _release-0.11.1:
 
 0.11.1 (2025-05-04)
diff -Nru debusine-0.11.1/pyproject.toml debusine-0.11.3/pyproject.toml
--- debusine-0.11.1/pyproject.toml	2025-05-04 13:00:19.000000000 +0200
+++ debusine-0.11.3/pyproject.toml	2025-07-08 16:09:29.000000000 +0200
@@ -93,7 +93,8 @@
 ]
 tests = [
   "cryptography",                   # deb: python3-cryptography
-  "lxml >= 4.9.23",                 # deb: python3-lxml
+  # https://salsa.debian.org/freexian-team/debusine/-/issues/953
+  "lxml >= 4.9.23, < 6.0.0",        # deb: python3-lxml
   "paramiko",                       # deb: python3-paramiko
   "pyftpdlib",                      # deb: python3-pyftpdlib
   "responses >= 0.18.0",            # deb: python3-responses (>= 0.18.0)

--- End Message ---
--- Begin Message ---
unblocked

--- End Message ---

Reply to: