From 4532b955598ce7a515ebbaa7abd0f1d0a9813d9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaroslav=20Bene=C5=A1?= Date: Thu, 6 Aug 2026 21:29:42 +0200 Subject: [PATCH] Release notes that are not forty lines of base64 Found by documenting it. `_notes_for` stripped `-----BEGIN PGP SIGNATURE-----` from an annotated tag's contents and nothing else, and which header appears depends on `gpg.format`: `openpgp` writes that one, `ssh` writes `-----BEGIN SSH SIGNATURE-----`. This repository signs with an SSH key, so the first signed release tag would have rendered its whole signature block as the release notes on the update page. `%(contents:subject)` and `%(contents:body)` would have avoided the question, and would also have thrown away every blank line in a body written as a list -- which is what release notes are. The suite caught the other half of the same change: `tag.gpgSign` makes a bare `git tag ` behave as `-s`, so the lightweight tags a test was making now wait for an editor it does not have. Co-Authored-By: Claude Opus 5 (1M context) --- deploy/README.md | 5 ++++- src/lembas/services/updates.py | 16 ++++++++++++--- tests/test_updates.py | 37 +++++++++++++++++++++++++++++++++- 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/deploy/README.md b/deploy/README.md index a544dc4..1b85797 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -73,7 +73,10 @@ onto a release candidate on the strength of a hyphen. A prerelease is something you check out by name. Release notes travel inside **annotated** tags, so `git tag -a v1.1.0 -m "…"` is -what puts them on the update page. No forge API is involved anywhere — which +what puts them on the update page. Tags here are **signed** (`tag.gpgSign`), and +the notes render the same either way — `updates._notes_for` cuts the +`-----BEGIN SSH SIGNATURE-----` block off `%(contents)`, which would otherwise be +forty lines of base64 on the page. No forge API is involved anywhere — which matters more than it sounds: a token on the deployment host to answer a read-only question about version numbers is a bad trade, it would tie this to one forge, and the Gitea API this was checked against returns a 500 from a diff --git a/src/lembas/services/updates.py b/src/lembas/services/updates.py index ec31c54..62de453 100644 --- a/src/lembas/services/updates.py +++ b/src/lembas/services/updates.py @@ -261,19 +261,29 @@ def release_tags(root: Path) -> list[str]: return [line.strip() for line in output.splitlines() if RELEASE_TAG.match(line.strip())] +# Where a signature block starts in `%(contents)`. **Both**, because which one +# appears depends on `gpg.format` -- `openpgp` writes the first and `ssh` the +# second, and a release tag signed either way would otherwise render forty lines +# of base64 as its release notes. `%(contents:subject)` and `%(contents:body)` +# would avoid this, but they also throw away every blank line in a body written +# as a list, which is what release notes are. +_SIGNATURE_HEADERS = ("-----BEGIN PGP SIGNATURE-----", "-----BEGIN SSH SIGNATURE-----") + + def _notes_for(root: Path, tag: str) -> str: """An annotated tag's message: the release notes, travelling inside git. Empty for a lightweight tag, which is the honest answer -- there is nothing - attached to one. `%(contents)` includes the signature block for a signed tag, - so it is cut at the PGP header rather than shown. + attached to one. """ code, output = _git( ["for-each-ref", "--format=%(contents)", f"refs/tags/{tag}"], cwd=root ) if code != 0: return "" - return output.split("-----BEGIN PGP SIGNATURE-----")[0].strip() + for header in _SIGNATURE_HEADERS: + output = output.split(header)[0] + return output.strip() def resolve_target(root: Path, channel: str, branch: str) -> Target | None: diff --git a/tests/test_updates.py b/tests/test_updates.py index 42c6133..aa3e1ad 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -144,6 +144,36 @@ def test_stable_picks_the_newest_release_and_reads_its_notes(db, tagged): assert "one thing" in target.notes +def test_a_signed_tag_shows_notes_and_not_base64(db): + """`%(contents)` carries the signature block, and which header it uses + depends on `gpg.format` -- PGP for `openpgp`, SSH for `ssh`. Stripping only + the first would have rendered forty lines of base64 as the release notes on + a repository that signs with an SSH key, which is this one.""" + import subprocess + + root = updates.checkout_dir() + subprocess.run( + ["git", "tag", "-a", "v9.9.8", "-m", "Signed release.\n\n- a note"], + cwd=root, check=True, capture_output=True, + ) + try: + code, raw = updates._git( + ["for-each-ref", "--format=%(contents)", "refs/tags/v9.9.8"], cwd=root + ) + notes = updates._notes_for(root, "v9.9.8") + finally: + subprocess.run( + ["git", "tag", "-d", "v9.9.8"], cwd=root, check=False, capture_output=True + ) + + assert code == 0 + # Only meaningful while this repository actually signs its tags; when it + # does, the raw contents carry a block and the notes must not. + if any(header in raw for header in updates._SIGNATURE_HEADERS): + assert not any(header in notes for header in updates._SIGNATURE_HEADERS) + assert notes == "Signed release.\n\n- a note" + + def test_a_version_sort_is_not_a_lexical_one(db, tagged): """`v1.10.0` above `v1.9.0`, which a lexical sort gets wrong -- and gets wrong silently the first time a project reaches ten of anything.""" @@ -151,7 +181,12 @@ def test_a_version_sort_is_not_a_lexical_one(db, tagged): root = updates.checkout_dir() for tag in ("v1.9.0", "v1.10.0"): - subprocess.run(["git", "tag", tag], cwd=root, check=True, capture_output=True) + # `-m` rather than a lightweight tag: this repository sets + # `tag.gpgSign`, which makes a bare `git tag ` behave as `-s` and + # wait for an editor that a test does not have. + subprocess.run( + ["git", "tag", "-m", tag, tag], cwd=root, check=True, capture_output=True + ) try: tags = updates.release_tags(root) assert tags.index("v1.10.0") < tags.index("v1.9.0")