mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 21:47:20 +00:00
c0208f5b80
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
1234 lines
55 KiB
Python
1234 lines
55 KiB
Python
#!/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 |
|
||
| --- | --- |
|
||
|  |  |
|
||
"""
|
||
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()
|