--- 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 ---