Package: release.debian.org
Severity: normal
User: release.debian....@packages.debian.org
Usertags: unblock
X-Debbugs-Cc: debus...@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 <cjwat...@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 <cjwat...@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 <cjwat...@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 <car...@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)                              [cjwat...@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 <cjwat...@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 <cjwat...@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)

Reply via email to