Files
pulse/scripts/release_control/render_release_body_test.py
T
pulse-triage[bot] c0208f5b80 Keep Mobile compatibility explicit beside its retirement notice
The containing install-metadata gate requires the existing companion-release disclosure. Retain it alongside, not instead of, the approved retirement notice; do not relax the gate or change the served runtime. Preserve the failed first proof and verify the final notes and actual producer.

Contract-Neutral: Notes-only compatibility disclosure and its assertion; no runtime, protocol, release identity, asset, maturity, latest pointer or cadence change.
Change-source: pulse-maintainer
2026-10-13 01:27:42 +01:00

1234 lines
55 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Regression tests for publish-safe release body rendering."""
from __future__ import annotations
import re
import json
import os
import subprocess
import textwrap
import tempfile
import unittest
from pathlib import Path
import render_release_body
_REPO_ROOT = Path(__file__).resolve().parents[2]
_RC_DRAFT_PACKET_NAME_RE = re.compile(r"^RELEASE_NOTES_v6_RC(\d+)_DRAFT\.md$")
def _discover_rc_draft_packet_paths() -> tuple[str, ...]:
"""Return release_notes + changelog + support_pack relpaths for every in-repo RC draft packet."""
paths: list[tuple[int, str]] = []
for path in sorted((_REPO_ROOT / "docs" / "releases").glob("RELEASE_NOTES_v6_RC*_DRAFT.md")):
match = _RC_DRAFT_PACKET_NAME_RE.match(path.name)
if not match:
continue
n = int(match.group(1))
paths.append((n, f"docs/releases/RELEASE_NOTES_v6_RC{n}_DRAFT.md"))
paths.append((n, f"docs/releases/V6_CHANGELOG_RC{n}_DRAFT.md"))
paths.append((n, f"docs/releases/V6_RC{n}_OPERATOR_SUPPORT_PACK_DRAFT.md"))
return tuple(rel for _, rel in sorted(paths))
class RenderReleaseBodyTest(unittest.TestCase):
def test_beta_two_notes_describe_included_outcomes_not_the_fallback(self) -> None:
notes = (_REPO_ROOT / "docs/releases/RELEASE_NOTES_v6.6.0-beta.2.md").read_text()
render_release_body.validate_release_notes_shape(notes, "6.6.0-beta.2")
improvements = notes.split("## What's improved\n", 1)[1].split("\n## ", 1)[0]
bullets = [line for line in improvements.splitlines() if line.startswith("- ")]
self.assertGreaterEqual(len(bullets), 33)
self.assertNotIn("**Fixes and improvements since v6.5.0.**", improvements)
for outcome in (
"**Debian identity**", "**Off stays off**", "**Redirect privacy**",
"**Removal recovery**", "**Unattended updates**", "**Token scope**",
):
with self.subTest(outcome=outcome):
self.assertIn(outcome, improvements)
self.assertIn("not a confirmed fix for every reported guest freeze", notes)
self.assertIn("not a confirmed fix for a higher resident-memory baseline", notes)
def test_beta_two_notes_and_changelog_use_the_same_stable_based_range(self) -> None:
notes = (_REPO_ROOT / "docs/releases/RELEASE_NOTES_v6.6.0-beta.2.md").read_text()
changelog = (_REPO_ROOT / "docs/releases/V6_CHANGELOG_v6.6.0-beta.2.md").read_text()
# The immutable tagged changelog still has a contradictory beta.1
# introduction. Use the renderer's stable-based comparison instead of
# suggesting that a later source edit changed that published document.
self.assertNotIn("/blob/v6.6.0-beta.2/docs/releases/V6_CHANGELOG", notes)
self.assertIn("not just the previous beta", notes)
self.assertIn("source comparison below", notes)
self.assertIn("since stable `v6.5.0`, not just", changelog)
self.assertIn("## Changes since v6.5.0\n", changelog)
self.assertNotIn("This changelog describes the changes since `v6.6.0-beta.1`.", changelog)
self.assertIn("This beta follows `v6.6.0-beta.1`", notes)
def test_beta_two_corrected_notes_keep_safe_install_and_rollback_rendering(self) -> None:
notes = (_REPO_ROOT / "docs/releases/RELEASE_NOTES_v6.6.0-beta.2.md").read_text()
args = type("Args", (), {
"version": "6.6.0-beta.2", "rollback_target": "v6.5.0",
"rollback_command": "sudo /bin/update --version v6.5.0",
"candidate_tag": "v6.6.0-beta.2",
})()
body = "\n\n".join((
render_release_body.sanitize_release_notes(notes, args.version).rstrip("\n"),
render_release_body.build_installation_section(args.version),
render_release_body.build_rollback_section(args),
render_release_body.build_source_comparison(args),
)) + "\n"
self.assertEqual(render_release_body.validate_release_body_shape(body, args.version), body)
self.assertIn("not Authenticode-signed", body)
self.assertIn("Unknown Publisher warning", body)
self.assertIn(
"Pulse Mobile is being retired on 31 March 2027. It keeps working until then. "
"For alerts on your phone afterwards, add an ntfy, Gotify, or Pushover "
"destination, and open Pulse in your phone's browser. Relay is no longer sold, "
"and existing Relay subscribers now have Pro features at their current price.",
body,
)
self.assertIn("does not require a companion mobile release", body)
self.assertIn(
"https://github.com/rcourtman/Pulse/compare/v6.5.0...v6.6.0-beta.2", body,
)
self.assertIn("GitHub shows at most 250 commits", body)
self.assertIn("rollback target is stable `v6.5.0`", body)
self.assertIn("PULSE_VERSION=v6.6.0-beta.2", body)
self.assertIn("PULSE_VERSION=v6.5.0", body)
self.assertIn("docker pull rcourtman/pulse:6.6.0-beta.2", body)
self.assertIn("The community-scripts updater can ignore `--version`", body)
def grouped_notes(self, version: str = "6.4.6-rc.1") -> str:
return f"""# Pulse v{version} Release Notes
PBS History stays with the current host and update progress is clearer.
## Highlights
- Replaced PBS hosts no longer return in History.
- Update progress follows the work until it finishes.
## Proxmox, PBS and backups
- History no longer brings back a replaced PBS host (#2343).
## Updates and agents
- Update progress no longer stays on Downloading 10% after the update completes.
## Known issues
- Some combined API and agent PBS layouts still lack History. Use the API view.
## Before you upgrade
Back up your data directory and keep the backup until you have checked the update.
"""
def test_next_patch_accepts_grouped_plain_language_notes(self) -> None:
for version in ("6.4.6-rc.1", "6.4.6", "6.5.0-rc.1", "6.5.0"):
with self.subTest(version=version):
notes = self.grouped_notes(version)
render_release_body.validate_release_notes_shape(notes, version)
args = type("Args", (), {"version": version,
"rollback_target": "v6.4.5",
"rollback_command": "sudo /bin/update --version v6.4.5"})()
body = "\n\n".join((notes.strip(),
render_release_body.build_installation_section(version),
render_release_body.build_rollback_section(args))) + "\n"
render_release_body.validate_release_body_shape(body, version, expected_body=body)
self.assertIn("## Proxmox, PBS and backups", body)
self.assertEqual(body.count("## Roll back\n"), 1)
self.assertIn("only when `/bin/update` was installed by the Pulse server installer", body)
def test_grouped_notes_keep_structure_and_customer_safety(self) -> None:
valid = self.grouped_notes()
cases = {
"wrong version": valid.replace("v6.4.6-rc.1", "v6.4.5"),
"empty group": valid.replace("- History no longer brings back a replaced PBS host (#2343).", ""),
"internal section": valid.replace("## Updates and agents", "## Release Qualification"),
"internal prose": valid.replace("after the update completes.", "after exact-SHA release gates passed."),
"long change": valid.replace("History no longer brings back a replaced PBS host (#2343).", "x" * 261),
"duplicate section": valid + "\n## Updates and agents\n\n- Another change.\n",
"duplicate change": valid.replace("Update progress no longer stays on Downloading 10% after the update completes.",
"History no longer brings back a replaced PBS host (#2343)."),
"flattened": valid.replace("\n\n## Updates and agents\n\n", " ## Updates and agents "),
"highlights only": "# Pulse v6.4.6-rc.1 Release Notes\n\nHistory is clearer.\n\n## Highlights\n\n- History stays with its host.\n",
}
for name, notes in cases.items():
with self.subTest(name=name):
with self.assertRaises(render_release_body.ReleaseBodyIntegrityError):
render_release_body.validate_release_notes_shape(notes, "6.4.6-rc.1")
def test_metadata_and_renderer_change_group_names_stay_aligned(self) -> None:
go_source = (_REPO_ROOT / "scripts/installtests/release_notes_contract_test.go").read_text()
group_map = go_source.split("var releaseNoteChangeHeadings = map[string]bool{", 1)[1].split("}", 1)[0]
groups = set(re.findall(r'"([^"]+)":\s*true', group_map))
self.assertEqual(groups, render_release_body._CUSTOMER_CHANGE_GROUPS | {"what's improved", "what’s improved"})
def test_generator_prompts_and_renderer_agree_on_grouped_notes(self) -> None:
# Invoke the actual shell authoring path with a deterministic, local
# model double. No model service, credentials or network are used.
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
scripts = root / "scripts/release_control"
scripts.mkdir(parents=True)
(scripts / "render_release_body.py").write_text(
(_REPO_ROOT / "scripts/release_control/render_release_body.py").read_text())
def git(*args: str) -> None:
subprocess.run(["git", "-c", "user.name=Release Note Test",
"-c", "user.email=notes@example.invalid", *args], cwd=root,
check=True, capture_output=True)
git("init", "-b", "main")
git("commit", "--allow-empty", "--no-gpg-sign", "-m", "previous stable")
git("tag", "v6.4.5")
git("commit", "--allow-empty", "--no-gpg-sign", "-m", "candidate changes")
bin_dir = root / "bin"
bin_dir.mkdir()
model = bin_dir / "codex"
model.write_text("""#!/usr/bin/env python3
import json, os, pathlib, sys
args = sys.argv[1:]
assert 'OPENAI_API_KEY' not in os.environ
with open(os.environ['NOTE_TEST_PROMPTS'], 'a') as trace:
trace.write(json.dumps(args[-1]) + '\\n')
pathlib.Path(args[args.index('-o') + 1]).write_text(pathlib.Path(os.environ['NOTE_TEST_NOTES']).read_text())
""")
model.chmod(0o755)
notes = root / "notes.md"
notes.write_text(self.grouped_notes())
prompts = root / "prompts.jsonl"
env = {**os.environ, "PATH": str(bin_dir) + os.pathsep + os.environ["PATH"],
"NOTE_TEST_NOTES": str(notes), "NOTE_TEST_PROMPTS": str(prompts),
"OPENAI_API_KEY": "synthetic-value-must-be-scrubbed"}
for key in ("SAVE_TO_FILE", "RELEASE_NOTES_TRACE_DIR", "RELEASE_NOTE_VISUAL_PLAN_FILE"):
env.pop(key, None)
result = subprocess.run(["bash", str(_REPO_ROOT / "scripts/generate-release-notes.sh"),
"6.4.6-rc.1", "v6.4.5"], cwd=root, env=env,
capture_output=True, text=True, timeout=30)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertEqual(result.stdout, notes.read_text())
recorded = [json.loads(line) for line in prompts.read_text().splitlines()]
self.assertEqual(len(recorded), 4) # research, draft, review, omission
for prompt in recorded[1:]:
self.assertIn("## Highlights", prompt)
self.assertIn("## Proxmox, PBS and backups", prompt)
self.assertIn("No internal verification status", prompt)
def test_v642_security_packet_keeps_both_admin_boundaries_visible(self) -> None:
notes = (
_REPO_ROOT / "docs" / "releases" / "RELEASE_NOTES_v6.4.2.md"
).read_text(encoding="utf-8")
for expected in (
"Infrastructure actions honor role boundaries",
"SSO access no longer implies administrator access",
"Partial Proxmox cluster coverage is visible",
"Agent Doctor reports privilege-helper degradation",
"Privileged workflows stay bound to canonical code",
"map at least one trusted IdP group to the built-in `admin` role before upgrading",
"The rollback target is stable `v6.4.1`",
):
self.assertIn(expected, notes)
self.assertNotIn("## Fixes", notes)
render_release_body.validate_release_notes_shape(notes, "6.4.2")
def test_v640_stable_packet_keeps_release_boundaries_visible(self) -> None:
notes = (
_REPO_ROOT / "docs" / "releases" / "RELEASE_NOTES_v6.4.0.md"
).read_text(encoding="utf-8")
for expected in (
"Alerts that survive restarts",
"More control over notifications",
"Earlier resource warnings",
"More accurate Proxmox and PBS coverage",
"Pulse Mobile iOS build 12 and Android versionCode 9 remain compatible",
"not Authenticode-signed",
"Unknown Publisher warning",
"The rollback target is stable `v6.3.2`",
):
self.assertIn(expected, notes)
self.assertNotIn("## Fixes", notes)
render_release_body.validate_release_notes_shape(notes, "6.4.0")
def test_rc13_packet_keeps_typed_truenas_smart_evidence_visible(self) -> None:
notes = (
_REPO_ROOT / "docs" / "releases" / "RELEASE_NOTES_v6.4.0-rc.13.md"
).read_text(encoding="utf-8")
self.assertIn(
"expose supported uncorrectable-error and spare-reserve values",
notes,
)
render_release_body.validate_release_notes_shape(notes, "6.4.0-rc.13")
def test_highlights_are_a_small_plain_language_overview(self) -> None:
notes = """# Pulse v6.2.0 Release Notes
## Highlights
- Alerts now explain what went wrong and what to do next.
- Tables are easier to use on phones and small screens.
- Updates recover cleanly when an earlier installation was interrupted.
## Fixed
- Corrected a release issue.
"""
render_release_body.validate_release_notes_shape(notes, "6.2.0")
def test_generated_level_three_highlights_and_wrapped_bullets_are_supported(self) -> None:
notes = """# Pulse v6.2.0 Release Notes
## v6.2.0
### Highlights
- Alerts now explain what went wrong and what to do
next.
- Tables are easier to use on small screens.
### Bug Fixes
- Corrected a release issue.
"""
render_release_body.validate_release_notes_shape(notes, "6.2.0")
def test_highlights_reject_more_than_three_items(self) -> None:
notes = """# Pulse v6.2.0 Release Notes
## Highlights
- One.
- Two.
- Three.
- Four.
## Fixed
- Corrected a release issue.
"""
with self.assertRaisesRegex(
render_release_body.ReleaseBodyIntegrityError,
"at most 3 bullets",
):
render_release_body.validate_release_notes_shape(notes, "6.2.0")
def test_highlights_reject_long_or_formatted_items(self) -> None:
long_item = "A" * 141
long_notes = f"""# Pulse v6.2.0 Release Notes
## Highlights
- {long_item}
## Fixed
- Corrected a release issue.
"""
formatted_notes = """# Pulse v6.2.0 Release Notes
## Highlights
- Read the [upgrade guide](https://example.com) for details.
## Fixed
- Corrected a release issue.
"""
with self.assertRaisesRegex(
render_release_body.ReleaseBodyIntegrityError,
"140 characters or fewer",
):
render_release_body.validate_release_notes_shape(long_notes, "6.2.0")
with self.assertRaisesRegex(
render_release_body.ReleaseBodyIntegrityError,
"must use plain text",
):
render_release_body.validate_release_notes_shape(formatted_notes, "6.2.0")
def test_highlights_reject_bare_and_parenthesized_issue_references(self) -> None:
for reference in ("#123", "(#123)"):
with self.subTest(reference=reference):
notes = f"""# Pulse v6.2.0 Release Notes
## Highlights
- Alerts recover cleanly after an update {reference}.
## Fixed
- Corrected a release issue.
"""
with self.assertRaisesRegex(
render_release_body.ReleaseBodyIntegrityError,
"issue references",
):
render_release_body.validate_release_notes_shape(notes, "6.2.0")
def test_highlights_reject_common_github_issue_reference_variants(self) -> None:
references = (
"GH-123",
"rcourtman/Pulse#123",
"https://github.com/rcourtman/Pulse/issues/123",
"https://github.com/rcourtman/Pulse/pull/123",
)
for reference in references:
with self.subTest(reference=reference):
notes = f"""# Pulse v6.2.0 Release Notes
## Highlights
- Alerts recover cleanly after an update {reference}.
## Fixed
- Corrected a release issue.
"""
with self.assertRaisesRegex(
render_release_body.ReleaseBodyIntegrityError,
"issue references",
):
render_release_body.validate_release_notes_shape(notes, "6.2.0")
def test_highlights_preserve_legitimate_plain_text_with_hash_characters(self) -> None:
notes = """# Pulse v6.2.0 Release Notes
## Highlights
- C# service checks now recover cleanly after interrupted updates.
- F# applications keep their configured display names.
## Fixed
- Corrected a release issue.
"""
render_release_body.validate_release_notes_shape(notes, "6.2.0")
def test_release_notes_may_omit_highlights(self) -> None:
notes = """# Pulse v6.2.1 Release Notes
## Fixed
- Corrected a maintenance issue.
"""
render_release_body.validate_release_notes_shape(notes, "6.2.1")
def test_future_release_notes_require_customer_facing_structure(self) -> None:
notes = """# Pulse v6.4.0-rc.6 Release Notes
Pulse is faster and more predictable in larger environments.
## What's improved
- **Faster infrastructure views** — Tables stay responsive as estates grow.
- **Lighter realtime updates** — Pages do less work when resources change.
- **Safer API key handling** — Saved API keys are no longer returned to the browser.
## Before you upgrade
No manual migration is required.
"""
render_release_body.validate_release_notes_shape(notes, "6.4.0-rc.6")
def test_current_release_notes_reject_a_second_fixes_list(self) -> None:
notes = """# Pulse v6.4.0-rc.6 Release Notes
Pulse is faster and more predictable in larger environments.
## What's improved
- **Faster infrastructure views** — Tables stay responsive as estates grow.
## Fixes
- Infrastructure tables no longer stall in larger estates.
"""
with self.assertRaisesRegex(
render_release_body.ReleaseBodyIntegrityError,
"features and fixes once",
):
render_release_body.validate_release_notes_shape(notes, "6.4.0-rc.6")
def test_customer_facing_standard_exempts_only_the_already_cut_rc1(self) -> None:
self.assertFalse(
render_release_body._requires_customer_facing_standard("6.4.0-rc.1")
)
self.assertTrue(
render_release_body._requires_customer_facing_standard("6.4.0-beta.1")
)
self.assertTrue(
render_release_body._requires_customer_facing_standard("v6.4.0-rc.2")
)
self.assertTrue(
render_release_body._requires_customer_facing_standard("6.4.0")
)
def test_v640_rc1_packet_records_current_customer_fixes(self) -> None:
notes = (
_REPO_ROOT / "docs" / "releases" / "RELEASE_NOTES_v6.4.0-rc.1.md"
).read_text(encoding="utf-8")
render_release_body.validate_release_notes_shape(notes, "6.4.0-rc.1")
normalized_notes = re.sub(r"\s+", " ", notes)
self.assertIn(
"Stopped Docker and Podman containers no longer re-fire health alerts "
"from the stale health-check result retained by the container runtime.",
normalized_notes,
)
self.assertIn(
"Backup-location filters distinguish PBS servers and datastores so "
"local and off-site restore points can be reviewed independently.",
normalized_notes,
)
def test_future_release_notes_reject_internal_release_control_sections(self) -> None:
notes = """# Pulse v6.4.0-rc.2 Release Notes
Pulse is faster and more predictable in larger environments.
## What's improved
- **Faster infrastructure views** — Tables stay responsive as estates grow.
## Release Qualification
- All readiness assertions and release gates passed.
"""
with self.assertRaisesRegex(
render_release_body.ReleaseBodyIntegrityError,
"may only use",
):
render_release_body.validate_release_notes_shape(notes, "6.4.0-rc.2")
def test_future_release_notes_reject_internal_release_control_language(self) -> None:
notes = """# Pulse v6.4.0 Release Notes
Pulse is faster and more predictable in larger environments.
## What's improved
- **Safer releases** - Every immutable candidate now crosses an exact-SHA gate.
"""
with self.assertRaisesRegex(
render_release_body.ReleaseBodyIntegrityError,
"internal release-control language",
):
render_release_body.validate_release_notes_shape(notes, "6.4.0")
def test_future_release_notes_require_scannable_improvement_bullets(self) -> None:
notes = """# Pulse v6.4.0 Release Notes
Pulse is faster and more predictable in larger environments.
## What's improved
- Tables stay responsive as estates grow.
"""
with self.assertRaisesRegex(
render_release_body.ReleaseBodyIntegrityError,
"short bold outcome",
):
render_release_body.validate_release_notes_shape(notes, "6.4.0")
def test_customer_story_is_not_forced_into_a_fixed_item_count(self) -> None:
bullets = "\n".join(
f"- **Useful outcome {index}** - Users can understand change {index}."
for index in range(1, 8)
)
notes = f"""# Pulse v6.4.0 Release Notes
Pulse has a concise set of improvements across the product.
## What's improved
{bullets}
"""
render_release_body.validate_release_notes_shape(notes, "6.4.0")
def test_new_release_notes_reject_semicolons(self) -> None:
notes = """# Pulse v6.4.0 Release Notes
Pulse is faster; pages are steadier.
## What's improved
- **Faster pages** - Tables stay responsive as estates grow.
"""
with self.assertRaisesRegex(
render_release_body.ReleaseBodyIntegrityError,
"semicolons or em dashes",
):
render_release_body.validate_release_notes_shape(notes, "6.4.0")
def test_new_release_notes_reject_em_dashes(self) -> None:
notes = """# Pulse v6.4.0-rc.7 Release Notes
Pulse is faster and pages are steadier.
## What's improved
- **Faster pages** — Tables stay responsive as estates grow.
"""
with self.assertRaisesRegex(
render_release_body.ReleaseBodyIntegrityError,
"semicolons or em dashes",
):
render_release_body.validate_release_notes_shape(notes, "6.4.0-rc.7")
def test_punctuation_rule_grandfathers_published_v640_rc_packets(self) -> None:
self.assertFalse(
render_release_body._requires_plain_release_punctuation("6.4.0-rc.6")
)
self.assertTrue(
render_release_body._requires_plain_release_punctuation("6.4.0-rc.7")
)
self.assertTrue(
render_release_body._requires_plain_release_punctuation("6.4.0")
)
self.assertTrue(
render_release_body._requires_plain_release_punctuation("6.4.1")
)
def test_canonical_template_keeps_machine_process_out_of_customer_notes(self) -> None:
template = (
_REPO_ROOT / "docs/releases/RELEASE_NOTES_TEMPLATE.md"
).read_text(encoding="utf-8")
self.assertIn("## What's improved", template)
self.assertIn("## Before you upgrade", template)
self.assertIn("For an RC, cover only changes since the immediately preceding RC", template)
self.assertIn("For a stable GA release", template)
self.assertIn("an unbounded factual investigation", template)
self.assertIn("independent draft", template)
self.assertIn("The models decide what to", template)
self.assertIn("what matters, and how to tell", template)
self.assertIn("the release story", template)
self.assertIn("more than 260 characters", template)
self.assertIn("must not contain semicolons or em", template)
self.assertIn("dashes", template)
self.assertNotIn(";", template)
self.assertNotIn("—", template)
self.assertIn("Do not add a separate `Fixes` section", template)
self.assertNotIn("\n## Fixes\n", template)
self.assertIn("pipeline appends the `Install` and `Roll back` sections", template)
self.assertNotIn("## Release Qualification", template)
self.assertNotIn("## Promotion Metadata", template)
def test_v621_packet_documents_cached_update_verdict_age(self) -> None:
release_notes = (
_REPO_ROOT / "docs/releases/RELEASE_NOTES_v6.2.1.md"
).read_text(encoding="utf-8")
changelog = (
_REPO_ROOT / "docs/releases/V6_CHANGELOG_v6.2.1.md"
).read_text(encoding="utf-8")
render_release_body.validate_release_notes_shape(release_notes, "6.2.1")
self.assertIn('cached "Up to date" verdicts', release_notes)
self.assertIn('cached "Up to date"', changelog)
self.assertIn("#1601", release_notes)
self.assertIn("#1601", changelog)
def test_sanitize_release_notes_strips_draft_markers_duplicate_sections_and_draft_links(self) -> None:
raw = """# Pulse v6.0.0-rc.2 Draft Release Notes
_Draft only. Do not treat this as published until the governed `v6.0.0-rc.2`
tag and GitHub prerelease exist._
Intro paragraph.
## Operator References
- `docs/releases/V6_RC2_OPERATOR_SUPPORT_PACK_DRAFT.md`
- `docs/UPGRADE_v6.md`
## Installation
Old install section.
## Promotion Metadata
Old metadata section.
"""
sanitized = render_release_body.sanitize_release_notes(raw, "6.0.0-rc.2")
self.assertIn("# Pulse v6.0.0-rc.2 Release Notes", sanitized)
self.assertNotIn("Draft Release Notes", sanitized)
self.assertNotIn("Draft only. Do not treat this as published", sanitized)
self.assertNotIn("_DRAFT.md", sanitized)
self.assertIn("- `docs/UPGRADE_v6.md`", sanitized)
self.assertNotIn("## Installation", sanitized)
self.assertNotIn("## Promotion Metadata", sanitized)
def test_main_renders_concise_install_and_rollback_sections(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
notes_file = Path(tmp) / "notes.md"
output_file = Path(tmp) / "body.md"
notes_file.write_text(
"# Pulse v6.0.0-rc.2 Draft Release Notes\n\n"
"_Draft only. Do not treat this as published until the governed `v6.0.0-rc.2` tag and GitHub prerelease exist._\n\n"
"Body.\n\n"
"## Fixed\n\n"
"- Corrected a release issue.\n",
encoding="utf-8",
)
args = render_release_body.parse_args.__wrapped__ if hasattr(render_release_body.parse_args, "__wrapped__") else None
del args # satisfy linters if wrapped implementation changes later
namespace = type(
"Args",
(),
{
"version": "6.0.0-rc.2",
"release_notes_file": str(notes_file),
"output": str(output_file),
"promotion_channel": "rc",
"candidate_tag": "v6.0.0-rc.2",
"promoted_prerelease_tag": "",
"rollback_target": "v5.1.28",
"rollback_command": "sudo /bin/update --version v5.1.28",
"planned_ga_date": "",
"planned_v5_eos_date": "",
"hotfix_exception": "false",
"hotfix_reason": "",
"require_windows_signing": "false",
"unsigned_windows_exception": "false",
"unsigned_windows_reason": "",
},
)()
raw_text = Path(namespace.release_notes_file).read_text(encoding="utf-8")
sanitized = render_release_body.sanitize_release_notes(raw_text, namespace.version).rstrip("\n")
sections = [
sanitized,
render_release_body.build_installation_section(namespace.version),
render_release_body.build_rollback_section(namespace),
]
Path(namespace.output).write_text("\n\n".join(sections) + "\n", encoding="utf-8")
body = output_file.read_text(encoding="utf-8")
self.assertEqual(body.count("## Install"), 1)
self.assertEqual(body.count("## Roll back"), 1)
self.assertNotIn("## Promotion Metadata", body)
self.assertIn("sudo /bin/update --version v6.0.0-rc.2", body)
self.assertIn("docker pull rcourtman/pulse:6.0.0-rc.2", body)
self.assertIn("https://pulserelay.pro/download.html", body)
self.assertIn("The rollback target is `v5.1.28`", body)
self.assertIn("sudo /bin/update --version v5.1.28", body)
for section, target in (
(render_release_body.build_installation_section(namespace.version), "v6.0.0-rc.2"),
(render_release_body.build_rollback_section(namespace), "v5.1.28"),
):
self.assertLess(section.index("only when"), section.index("sudo /bin/update"))
self.assertIn("installed by the Pulse server installer", section)
self.assertIn("community-scripts updater can ignore `--version`", section)
self.assertIn(f"`PULSE_VERSION={target}`", section)
self.assertIn(
"/blob/v6.0.0-rc.2/docs/INSTALL.md#2-bare-metal--systemd", section,
)
self.assertNotIn("/blob/main/", section)
self.assertIn(
"For Docker Compose, set the Pulse image to the rollback target",
body,
)
render_release_body.validate_release_body_shape(body, "6.0.0-rc.2")
def test_release_body_accepts_visual_evidence_before_installation(self) -> None:
notes = """# Pulse v6.4.0 Release Notes
Pulse is easier to use on the screens operators already carry.
## What's improved
- **Clearer small-screen controls** - Settings remain readable on phones.
"""
visuals = """## See the difference
### Settings fit smaller screens
Controls remain readable without horizontal scrolling.
| Before | Now |
| --- | --- |
| ![Settings before](https://github.com/rcourtman/Pulse/releases/download/v6.4.0/release-note-settings-before.png) | ![Settings now](https://github.com/rcourtman/Pulse/releases/download/v6.4.0/release-note-settings-now.png) |
"""
rollback_args = type(
"Args",
(),
{
"version": "6.4.0",
"rollback_target": "v6.3.2",
"rollback_command": "sudo /bin/update --version v6.3.2",
},
)()
body = "\n\n".join(
[
notes.strip(),
visuals.strip(),
render_release_body.build_installation_section("6.4.0"),
render_release_body.build_rollback_section(rollback_args),
]
) + "\n"
self.assertEqual(
render_release_body.validate_release_body_shape(body, "6.4.0"),
body,
)
def test_updater_ownership_scope_cannot_be_borrowed_from_a_distant_warning(self) -> None:
args = type("Args", (), {"version": "6.3.2", "rollback_target": "v6.3.1",
"rollback_command": "sudo /bin/update --version v6.3.1"})()
authored = "# Pulse v6.3.2 Release Notes\n\nSummary.\n\n## Before you upgrade\n\n"
install = render_release_body.build_installation_section("6.3.2")
rollback = render_release_body.build_rollback_section(args)
safe = authored + "Keep a backup.\n\n" + install + "\n\n" + rollback
render_release_body.validate_release_body_shape(safe, "6.3.2")
unsafe = (
safe.replace("Use this CLI command only when `/bin/update` was installed by the Pulse server installer:",
"For systemd and Proxmox LXC installs, use:"),
safe.replace("For systemd and Proxmox LXC servers, use this command only when "
"`/bin/update` was installed by the Pulse server installer:", "Run:"),
safe.replace("Keep a backup.", "On systemd and Proxmox LXC installs, use "
"`sudo /bin/update --version v6.3.1`."),
safe.replace("Keep a backup.", "Use this CLI command only when `/bin/update` was installed "
"by the Pulse server installer.\n\nA separate unrelated bullet.\n\n"
"```bash\nsudo /bin/update --version v6.3.1\n```"),
)
for body in unsafe:
with self.subTest(body=body), self.assertRaisesRegex(
render_release_body.ReleaseBodyIntegrityError, "each /bin/update"):
render_release_body.validate_release_body_shape(body, "6.3.2")
render_release_body.validate_server_updater_guidance(
"Use this command only when `/bin/update` was installed by\n"
"the Pulse server installer: `sudo /bin/update --version v6.3.1`."
)
def test_flattened_release_notes_fail_closed(self) -> None:
flattened = (
"# Pulse v6.1.0-rc.2 Release Notes"
"`v6.1.0-rc.2` is a release candidate."
"## Highlights"
"- Patrol findings stay governed."
"## Upgrade Notes"
"Use the RC channel."
)
with self.assertRaisesRegex(
render_release_body.ReleaseBodyIntegrityError,
"standalone release title",
):
render_release_body.validate_release_notes_shape(
flattened,
"6.1.0-rc.2",
)
def test_stored_release_body_must_match_expected_rendered_markdown(self) -> None:
expected = """# Pulse v6.1.0-rc.2 Release Notes
Intro.
## Highlights
- Patrol findings stay governed.
## Install
Install details.
## Roll back
Rollback details.
"""
validation_block = """<!-- VALIDATION_STATUS_START -->
## Release Asset Validation: PASSED
Assets passed.
<!-- VALIDATION_STATUS_END -->
"""
stored = validation_block + expected
clean = render_release_body.validate_release_body_shape(
stored,
"6.1.0-rc.2",
expected_body=expected,
)
self.assertEqual(clean, expected)
with self.assertRaisesRegex(
render_release_body.ReleaseBodyIntegrityError,
"does not exactly match",
):
render_release_body.validate_release_body_shape(
stored,
"6.1.0-rc.2",
expected_body=expected.replace("Patrol", "Assistant"),
)
def test_stored_release_body_rejects_inline_headings(self) -> None:
flattened = """# Pulse v6.1.0-rc.2 Release Notes
Intro.## Highlights- Patrol findings stay governed.
## Install
Install details.
## Roll back
Rollback details.
"""
with self.assertRaisesRegex(
render_release_body.ReleaseBodyIntegrityError,
"flattened Markdown",
):
render_release_body.validate_release_body_shape(
flattened,
"6.1.0-rc.2",
)
def test_current_release_packets_use_pulse_mobile_handoff_copy(self) -> None:
repo_root = _REPO_ROOT
# Stable release notes are hardcoded; every in-repo RC draft packet is
# discovered from the filesystem so adding a new RC doesn't require
# editing this tuple.
packet_paths = ("docs/releases/RELEASE_NOTES_v6.md",) + _discover_rc_draft_packet_paths()
for relative_path in packet_paths:
with self.subTest(relative_path=relative_path):
text = (repo_root / relative_path).read_text(encoding="utf-8")
self.assertIn("Pulse Mobile pairing for handoff", text)
self.assertNotIn("mobile app pairing", text)
self.assertNotIn("remote access/mobile/push", text)
def test_rc3_packet_records_commit_coverage_and_release_artifact_hardening(self) -> None:
repo_root = Path(__file__).resolve().parents[2]
release_notes = (repo_root / "docs/releases/RELEASE_NOTES_v6_RC3_DRAFT.md").read_text(
encoding="utf-8"
)
changelog = (repo_root / "docs/releases/V6_CHANGELOG_RC3_DRAFT.md").read_text(
encoding="utf-8"
)
support_pack = (
repo_root / "docs/releases/V6_RC3_OPERATOR_SUPPORT_PACK_DRAFT.md"
).read_text(encoding="utf-8")
self.assertIn("158d65ccdb81077c35b9237a1652b2774ddb5d5c", release_notes)
self.assertIn("commit count: `605`", changelog)
self.assertIn("broad hardening RC with a corrective maintenance core", changelog)
self.assertIn("Community-tier capabilities", release_notes)
self.assertIn("stable-channel release resolution", release_notes)
self.assertIn("Release asset uploads use bounded retries", release_notes)
self.assertIn(
"release artifact validation, draft metadata preservation, upload retries",
support_pack,
)
def test_rc4_packet_records_commit_coverage_and_identity_hardening(self) -> None:
repo_root = Path(__file__).resolve().parents[2]
release_notes = (repo_root / "docs/releases/RELEASE_NOTES_v6_RC4_DRAFT.md").read_text(
encoding="utf-8"
)
changelog = (repo_root / "docs/releases/V6_CHANGELOG_RC4_DRAFT.md").read_text(
encoding="utf-8"
)
support_pack = (
repo_root / "docs/releases/V6_RC4_OPERATOR_SUPPORT_PACK_DRAFT.md"
).read_text(encoding="utf-8")
self.assertIn("7cebe788590d0485f65bf4e04830356204657e86", release_notes)
self.assertIn("commit count: `57`", changelog)
self.assertIn("stable identity principals", support_pack)
self.assertIn("API-first action planning", changelog)
self.assertIn("monitored-system and child-resource volume unmetered", release_notes)
self.assertIn("Pulse Mobile pairing for handoff", support_pack)
self.assertIn("pin Docker install defaults to `6.0.0-rc.4`", changelog)
self.assertIn("Docker Compose and turnkey Docker installer defaults", release_notes)
self.assertIn("release-validation\ncommits", changelog)
self.assertIn("Tenant monitor state broadcasts", release_notes)
self.assertIn("tenant\nmonitor broadcast guard", changelog)
self.assertIn("live auth-env watcher teardown", release_notes)
self.assertIn("join live config watcher goroutines", changelog)
def test_rc5_packet_records_commit_coverage_and_agent_substrate(self) -> None:
repo_root = Path(__file__).resolve().parents[2]
release_notes = (repo_root / "docs/releases/RELEASE_NOTES_v6_RC5_DRAFT.md").read_text(
encoding="utf-8"
)
changelog = (repo_root / "docs/releases/V6_CHANGELOG_RC5_DRAFT.md").read_text(
encoding="utf-8"
)
support_pack = (
repo_root / "docs/releases/V6_RC5_OPERATOR_SUPPORT_PACK_DRAFT.md"
).read_text(encoding="utf-8")
self.assertIn("e36945741e1db5d763ab63eeeda18a58acda23c5", release_notes)
self.assertIn("commit count: `428`", changelog)
self.assertIn("agent-substrate HTTP contract", release_notes)
self.assertIn("/api/agent/capabilities", changelog)
self.assertIn("Pulse Intelligence", support_pack)
self.assertIn("operator-state", changelog)
self.assertIn("Pulse Mobile pairing for handoff", support_pack)
def test_shipped_rc1_notes_document_current_agent_upgrade_surface(self) -> None:
repo_root = Path(__file__).resolve().parents[2]
release_notes = (repo_root / "docs/releases/RELEASE_NOTES_v6_RC1.md").read_text(
encoding="utf-8"
)
self.assertIn("Settings -> Infrastructure -> Install on a host", release_notes)
self.assertIn("first installs and in-place upgrades", release_notes)
self.assertIn("after an upgraded agent authenticates", release_notes)
self.assertNotIn("Settings -> Agents -> Installation commands", release_notes)
self.assertNotIn("Settings → Agents → Installation commands", release_notes)
def test_agent_paradigm_release_notes_blurb_documents_distribution_path(self) -> None:
"""The agent-paradigm source draft must keep its honest scope:
an integrator reading the blurb sees a published distribution
path (the install-mcp script + GitHub Release binaries) when
the work lands, not the earlier "build from source" wording.
Pin the blurb's stable touchstones so a future edit that
accidentally regresses the install story (e.g. swaps the
one-line installer for "clone the repo" again) fails this
test instead of shipping silently into a release.
"""
repo_root = Path(__file__).resolve().parents[2]
blurb = (repo_root / "docs/releases/AGENT_PARADIGM.md").read_text(encoding="utf-8")
self.assertIn("install-mcp.sh", blurb, "blurb must reference the published install script")
self.assertIn("/api/agent/capabilities", blurb)
self.assertIn("cmd/pulse-mcp", blurb)
self.assertIn("cmd/agent-probe", blurb)
self.assertIn("OpenCode, other MCP clients", blurb)
self.assertIn("client-ready MCP config snippets", blurb)
self.assertIn("OpenCode's native", blurb)
self.assertIn("common `mcpServers` shape", blurb)
self.assertIn("Drivable from MCP clients in one command", blurb)
self.assertIn("Wire it into any MCP-speaking client", blurb)
self.assertIn("manifest `requiredScopes`", blurb)
self.assertIn("read-only subset", blurb)
self.assertNotIn("common MCP config snippet", blurb)
self.assertNotIn("clients that accept\n `mcpServers`", blurb)
self.assertNotIn("Claude Desktop / Claude Code", blurb)
self.assertNotIn("Drivable from Claude in one command", blurb)
self.assertNotIn("adapter for Claude Desktop and Claude Code", blurb)
self.assertNotIn("`monitoring:read` (and", blurb)
# The four-axis frame is the substrate's load-bearing claim;
# if any axis name drifts in the blurb, agents reading
# release notes will look for a different surface than what
# ships.
self.assertIn("Discovery", blurb)
self.assertIn("Read", blurb)
self.assertIn("Write", blurb)
self.assertIn("Push", blurb)
class AssetValidationWordingTest(unittest.TestCase):
def render_banner(self, draft: bool) -> str:
source = (_REPO_ROOT / ".github/workflows/validate-release-assets.yml").read_text(encoding="utf-8")
# Execute only the actual local wording branch and banner heredoc,
# never the workflow's GitHub reads/writes or release mutation code.
branch = source.split(' if [ "$INITIAL_STATE" = "true" ]; then\n', 1)[1]
branch = 'if [ "$INITIAL_STATE" = "true" ]; then\n' + branch.split(
"\n fi", 1
)[0] + "\nfi\n"
template = textwrap.dedent(source.split(
" read -r -d '' VALIDATION_BLOCK <<'EOF' || true\n", 1
)[1].split("\n EOF", 1)[0])
result = subprocess.run(
["bash", "-eu", "-c", 'INITIAL_STATE="$1"\n' + branch
+ "printf '%s\\n' \"$HEADER_LINE\" \"$INTRO_LINE\" \"$STATUS_LINE\"",
"wording-test", str(draft).lower()],
check=True, capture_output=True, text=True, timeout=5,
)
header, intro, status = result.stdout.splitlines()
return template.replace("HEADER_PLACEHOLDER", header).replace(
"INTRO_PLACEHOLDER", intro
).replace("STATUS_PLACEHOLDER", status)
def test_draft_reports_asset_checks_not_publication_readiness(self) -> None:
banner = self.render_banner(True)
self.assertIn("**Status**: Release asset checks passed", banner)
self.assertNotIn("Ready for publication", banner)
self.assertIn("## ✅ Release Asset Validation: PASSED", banner)
def test_published_release_still_reports_revalidation(self) -> None:
banner = self.render_banner(False)
self.assertIn("Release Asset Validation (Post-Publish): PASSED", banner)
self.assertIn("Live release assets re-validated", banner)
self.assertNotIn("Ready for publication", banner)
def test_both_banners_explain_limits_without_losing_check_summary(self) -> None:
for draft in (True, False):
with self.subTest(draft=draft):
banner = self.render_banner(draft)
self.assertIn("Asset validation alone does not establish installed-service health,", banner)
self.assertIn("release convergence, clean soak or approval for stable publication.", banner)
for check in ("All required assets present", "Checksums verified",
"Version strings correct", "Binary architectures validated"):
self.assertIn(check, banner)
self.assertIn("<!-- VALIDATION_STATUS_START -->", banner)
self.assertIn("<!-- VALIDATION_STATUS_END -->", banner)
class SourceComparisonTests(unittest.TestCase):
"""The advisory tag-bound pointer to GitHub's comparison, and its place in the rendered body."""
NOTES = _REPO_ROOT / "docs" / "releases" / "RELEASE_NOTES_v6.5.0.md"
@staticmethod
def args(tag: str | None, base: str | None) -> "argparse.Namespace":
import argparse
return argparse.Namespace(candidate_tag=tag, rollback_target=base)
def render(self, version: str, tag: str, base: str, notes: Path | None = None) -> subprocess.CompletedProcess:
with tempfile.TemporaryDirectory() as directory:
output = Path(directory) / "body.md"
result = subprocess.run(
["python3", str(_REPO_ROOT / "scripts/release_control/render_release_body.py"),
"--version", version, "--release-notes-file", str(notes or self.NOTES),
"--output", str(output), "--promotion-channel", "stable-rc-promotion",
"--candidate-tag", tag, "--promoted-prerelease-tag", "", "--rollback-target", base,
"--rollback-command", f"sudo /bin/update --version {base}",
"--planned-ga-date", "", "--planned-v5-eos-date", "", "--hotfix-exception", "false",
"--hotfix-reason", "", "--require-windows-signing", "false",
"--unsigned-windows-exception", "false", "--unsigned-windows-reason", ""],
capture_output=True, text=True, cwd=_REPO_ROOT)
result.body = output.read_text(encoding="utf-8") if output.exists() else ""
return result
def test_section_binds_the_exact_tag_and_stable_base(self) -> None:
section = render_release_body.build_source_comparison(self.args("v6.6.0-beta.3", "v6.5.0"))
self.assertIsNotNone(section)
self.assertTrue(section.startswith("## Source comparison\n"))
self.assertIn("https://github.com/rcourtman/Pulse/compare/v6.5.0...v6.6.0-beta.3)", section)
# GitHub caps a comparison at 250 commits, so this must not claim to be a full list.
self.assertIn("at most 250 commits", section)
self.assertIn("may omit commits", section)
self.assertNotIn("Full list", section)
# Stable promotion compares the same stable base with the final tag.
self.assertIn("compare/v6.5.0...v6.6.0)", render_release_body.build_source_comparison(self.args("v6.6.0", "v6.5.0")))
def test_missing_or_unusable_context_renders_nothing_and_never_raises(self) -> None:
huge = "v" + "1" * 5000 + ".0.0"
for tag, base in [(None, "v6.5.0"), ("v6.6.0-beta.3", None), ("", ""), ("6.6.0", "v6.5.0"),
("v6.6.0-beta.3", "6.5.0"), ("v6.6.0-beta.3", "v6.5.0-rc.1"),
("v6.6.0-beta.3", "v6.6.0"), ("v6.5.0", "v6.5.0"), ("v6.5.0", "v6.6.0"),
("v6.6.0-beta.0", "v6.5.0"), ("v6.6.0-beta.3\n#", "v6.5.0"),
# a final newline would put a line break inside the URL
("v6.5.0\n", "v6.4.5"), ("v6.5.0", "v6.4.5\n"), ("v6.6.0-beta.3\n", "v6.5.0"),
# non-ASCII digits, and numbers too large to convert, must not raise
("v\uff16.\uff15.\uff10", "v6.4.5"), ("v6.5.0", "v\uff16.\uff14.\uff15"),
(huge, "v6.4.5"), ("v6.5.0", huge), ("v6.5.0", "v6.4." + "9" * 7),
("v6.6.0-beta." + "9" * 7, "v6.5.0")]:
with self.subTest(tag=tag, base=base):
self.assertIsNone(render_release_body.build_source_comparison(self.args(tag, base)))
def test_rendered_body_ends_with_the_comparison_after_roll_back_and_validates(self) -> None:
result = self.render("6.5.0", "v6.5.0", "v6.4.5")
self.assertEqual(0, result.returncode, result.stderr)
headings = re.findall(r"(?m)^## (.+)$", result.body)
self.assertEqual(["Install", "Roll back", "Source comparison"], headings[-3:])
self.assertEqual(1, result.body.count("## Source comparison"))
self.assertIn("compare/v6.4.5...v6.5.0)", result.body)
# The stored body round-trips through the validator the edit and recovery workflows use.
render_release_body.validate_release_body_shape(result.body, "6.5.0")
clean = render_release_body.validate_release_body_shape(result.body, "6.5.0", expected_body=result.body)
self.assertIn("## Source comparison", clean)
def test_the_base_is_the_supplied_stable_rollback_target_and_orders_numerically(self) -> None:
build = render_release_body.build_source_comparison
# Numeric, not lexical: patch 10 follows patch 9. Alpha and release candidate heads work like betas.
self.assertIn("compare/v6.4.9...v6.4.10)", build(self.args("v6.4.10", "v6.4.9")))
self.assertIsNone(build(self.args("v6.4.9", "v6.4.10")))
self.assertIn("compare/v6.5.0...v6.6.0-rc.2)", build(self.args("v6.6.0-rc.2", "v6.5.0")))
self.assertIn("compare/v6.5.0...v6.6.0-alpha.1)", build(self.args("v6.6.0-alpha.1", "v6.5.0")))
# A hotfix and an older, non-immediate rollback target are used exactly as supplied.
self.assertIn("compare/v6.4.1...v6.5.1)", build(self.args("v6.5.1", "v6.4.1")))
def test_section_text_is_separated_by_one_blank_line_and_ends_with_one_newline(self) -> None:
result = self.render("6.5.0", "v6.5.0", "v6.4.5")
self.assertTrue(result.body.endswith("larger comparisons may omit commits.\n"), result.body[-120:])
self.assertFalse(result.body.endswith("\n\n"))
self.assertIn("\n\n## Source comparison\n\n[Browse source changes", result.body)
# Exactly one blank line before the section, as between every other section.
self.assertNotIn("\n\n\n## Source comparison", result.body)
before = result.body.split("\n\n## Source comparison")[0]
self.assertTrue(before.rstrip("\n").endswith("recreate the container."), before[-80:])
self.assertFalse(before.endswith("\n"))
self.assertIn(") on GitHub for more detail behind this summary.", result.body)
def test_edit_and_annotation_cycles_preserve_the_section_exactly(self) -> None:
# validate-release-assets.yml re-reads the stored body, strips its status block, prepends a
# fresh one and checks equality with the cleaned body. Repeat that, with the extra newline
# `jq -r` adds and with CRLF line endings, and the section must survive unchanged.
result = self.render("6.5.0", "v6.5.0", "v6.4.5")
block = ("<!-- VALIDATION_STATUS_START -->\n## Release Asset Validation: PASSED\n\n"
"**Validated**: 2026-10-13 00:00:00 UTC\n\n<!-- VALIDATION_STATUS_END -->\n\n")
clean = render_release_body.validate_release_body_shape(result.body, "6.5.0")
for cycle in range(10):
stored = block + clean + ("\n" if cycle % 2 else "")
clean = render_release_body.validate_release_body_shape(stored, "6.5.0", expected_body=None)
crlf = render_release_body.validate_release_body_shape(stored.replace("\n", "\r\n"), "6.5.0")
self.assertEqual(clean.rstrip("\n"), crlf.rstrip("\n"))
self.assertEqual(result.body.rstrip("\n"), clean.rstrip("\n"))
self.assertEqual(1, clean.count("## Source comparison"))
def test_expected_body_equality_rejects_a_changed_or_deleted_section_but_not_its_absence(self) -> None:
result = self.render("6.5.0", "v6.5.0", "v6.4.5")
render_release_body.validate_release_body_shape(result.body, "6.5.0", expected_body=result.body)
changed = result.body.replace("compare/v6.4.5...v6.5.0", "compare/v6.4.4...v6.5.0")
deleted = result.body.split("\n\n## Source comparison")[0] + "\n"
for label, stored in (("changed link", changed), ("deleted section", deleted)):
with self.subTest(label=label):
with self.assertRaises(render_release_body.ReleaseBodyIntegrityError):
render_release_body.validate_release_body_shape(stored, "6.5.0", expected_body=result.body)
# Shape-only validation, and equality against an expected body that also lacks it, still accept absence.
render_release_body.validate_release_body_shape(deleted, "6.5.0")
render_release_body.validate_release_body_shape(deleted, "6.5.0", expected_body=deleted)
def test_a_base_that_smuggles_a_heading_is_still_stopped_by_the_existing_checks(self) -> None:
# The helper itself skips this input and raises nothing, but the renderer's existing body
# checks still apply to the whole render, and the rollback command and target are rendered
# elsewhere in the body, so this fails there. The contract says exactly that.
smuggled = "v6.4.5\n## Install\n"
self.assertIsNone(render_release_body.build_source_comparison(self.args("v6.5.0", smuggled)))
result = self.render("6.5.0", "v6.5.0", smuggled)
self.assertNotEqual(0, result.returncode)
self.assertNotIn("## Source comparison", result.body)
def test_a_malformed_base_does_not_fail_the_render(self) -> None:
result = self.render("6.5.0", "v6.5.0", "v6.5.0-rc.1")
self.assertEqual(0, result.returncode, result.stderr)
self.assertNotIn("## Source comparison", result.body)
def test_bodies_without_the_section_still_validate(self) -> None:
# Everything published before this section existed, and any frozen in-flight release,
# has no such section. It is advisory: the absence must keep validating.
result = self.render("6.5.0", "v6.5.0", "v6.4.5")
legacy = result.body.split("\n\n## Source comparison")[0] + "\n"
self.assertNotIn("Source comparison", legacy)
render_release_body.validate_release_body_shape(legacy, "6.5.0")
if __name__ == "__main__":
unittest.main()