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)