From cf9529eda57110f315f1db2e5506edfa0261b1d5 Mon Sep 17 00:00:00 2001 From: Seth Van Niekerk Date: Thu, 15 Oct 2026 17:58:58 -0400 Subject: [PATCH] fix(workflows): restore manifest signing newline Sign the compact manifest payload with jq's terminating newline so Dispatcharr reconstructs the same bytes during GPG verification. Add a regression test for the signing input. --- .../src/pluginctl/publish/manifest.py | 4 +++- .github/pluginctl/tests/test_manifest.py | 22 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/.github/pluginctl/src/pluginctl/publish/manifest.py b/.github/pluginctl/src/pluginctl/publish/manifest.py index a8bac4cd..76939c3a 100644 --- a/.github/pluginctl/src/pluginctl/publish/manifest.py +++ b/.github/pluginctl/src/pluginctl/publish/manifest.py @@ -222,7 +222,9 @@ def sign_manifest(file: str, gpg_key_id: str, passphrase: Optional[str]) -> bool return True with open(file, encoding="utf-8") as fh: data = json.load(fh) - payload = jsonio.dumps(data["manifest"]) + # jq -c writes a terminating newline. Dispatcharr reconstructs this exact + # historical payload before verification, so it is part of the signature. + payload = jsonio.dumps(data["manifest"]) + "\n" args = ["gpg", "--batch", "--yes", "--armor", "--detach-sign", "--local-user", gpg_key_id, "--output", "-"] if passphrase: diff --git a/.github/pluginctl/tests/test_manifest.py b/.github/pluginctl/tests/test_manifest.py index e8c2fa76..19c10236 100644 --- a/.github/pluginctl/tests/test_manifest.py +++ b/.github/pluginctl/tests/test_manifest.py @@ -146,6 +146,28 @@ def test_write_manifest_uses_formatted_json_and_ignores_existing_whitespace(tmp_ assert dest.read_text(encoding="utf-8") == json.dumps({"manifest": payload}) +def test_sign_manifest_signs_compact_payload_with_jq_newline(tmp_path, monkeypatch): + path = tmp_path / "manifest.json" + manifest = {"registry_name": "demo", "plugins": []} + path.write_text(json.dumps({"manifest": manifest}), encoding="utf-8") + captured = {} + + class Result: + returncode = 0 + stdout = "signature\n" + + def run(args, **kwargs): + captured["args"] = args + captured["input"] = kwargs["input"] + return Result() + + monkeypatch.setattr(m.subprocess, "run", run) + + assert m.sign_manifest(str(path), "0123456789ABCDEF", None) + assert captured["input"] == jsonio.dumps(manifest) + "\n" + assert json.loads(path.read_text(encoding="utf-8"))["signature"] == "signature" + + def test_pages_url_returns_empty_string_when_pages_is_unavailable(monkeypatch): monkeypatch.setattr(gh, "api", lambda *args, **kwargs: None) assert gh.pages_url("org/repo") == ""