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 <name>` 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) <noreply@anthropic.com>
This commit is contained in:
+4
-1
@@ -73,7 +73,10 @@ onto a release candidate on the strength of a hyphen. A prerelease is something
|
|||||||
you check out by name.
|
you check out by name.
|
||||||
|
|
||||||
Release notes travel inside **annotated** tags, so `git tag -a v1.1.0 -m "…"` is
|
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
|
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
|
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
|
one forge, and the Gitea API this was checked against returns a 500 from a
|
||||||
|
|||||||
@@ -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())]
|
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:
|
def _notes_for(root: Path, tag: str) -> str:
|
||||||
"""An annotated tag's message: the release notes, travelling inside git.
|
"""An annotated tag's message: the release notes, travelling inside git.
|
||||||
|
|
||||||
Empty for a lightweight tag, which is the honest answer -- there is nothing
|
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,
|
attached to one.
|
||||||
so it is cut at the PGP header rather than shown.
|
|
||||||
"""
|
"""
|
||||||
code, output = _git(
|
code, output = _git(
|
||||||
["for-each-ref", "--format=%(contents)", f"refs/tags/{tag}"], cwd=root
|
["for-each-ref", "--format=%(contents)", f"refs/tags/{tag}"], cwd=root
|
||||||
)
|
)
|
||||||
if code != 0:
|
if code != 0:
|
||||||
return ""
|
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:
|
def resolve_target(root: Path, channel: str, branch: str) -> Target | None:
|
||||||
|
|||||||
+36
-1
@@ -144,6 +144,36 @@ def test_stable_picks_the_newest_release_and_reads_its_notes(db, tagged):
|
|||||||
assert "one thing" in target.notes
|
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):
|
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
|
"""`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."""
|
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()
|
root = updates.checkout_dir()
|
||||||
for tag in ("v1.9.0", "v1.10.0"):
|
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 <name>` 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:
|
try:
|
||||||
tags = updates.release_tags(root)
|
tags = updates.release_tags(root)
|
||||||
assert tags.index("v1.10.0") < tags.index("v1.9.0")
|
assert tags.index("v1.10.0") < tags.index("v1.9.0")
|
||||||
|
|||||||
Reference in New Issue
Block a user