mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-25 21:20:00 +00:00
0bd6e8b9d8
The first beta's page listed no changes, and the full changelog file is neither attached to nor linked from any release page, so a reader could not look up a change the summary did not name. Add a pointer, advisory and additive. The renderer appends a "## Source comparison" section after Roll back: a link to GitHub's comparison of the release tag with the stable rollback target the workflow already passes (--rollback-target and --candidate-tag). It is a pointer, not a list. GitHub shows at most 250 commits in a comparison and this beta had 328 changes, so the section says larger comparisons may omit commits and is not called a full list. It never fails a release on its own. The helper renders nothing, and raises nothing, for a missing or malformed tag, a base that is not a lower stable release, a trailing newline, non-ASCII digits, or numbers too large to convert. Bodies without the section, including every earlier release and any frozen in-flight one, keep validating, because the validator already permits a trailing section and is unchanged. The post-update changelog dialog reads only recognised category headings, so a separate heading after Roll back is ignored by it. Every consumer of the rendered body was audited (the renderer's validators, create-release, validate-release-assets, recover-release-activation, the updater backend, the what's-new model and the Go and TypeScript tests): no regression. Review of the first draft found a crash on a 5000-digit tag, a newline that would land inside the URL, and overclaiming contract text; all fixed and pinned by tests that fail against the first draft. A tags/ prefix would avoid a branch that shares a tag's name, but GitHub's web comparison URL returned 404 for it on existing tags, so bare refs are used and the limit is recorded. Tests run the real renderer on the v6.5.0 notes: placement after Roll back, exact spacing, numeric ordering, ten edit and annotation cycles with CRLF and the extra jq newline, expected-body rejection of a changed or deleted section, and absence still validating. They fail against the original renderer. Making the section required, and a generated row-level inventory that proves completeness, need a declarative contract record in the preparation snapshot and are deliberately deferred; the contract explains why. Contract-Neutral: Adds an advisory link to rendered release pages; no published artifact, tag, signing, install or customer contract changes.
740 lines
28 KiB
Python
740 lines
28 KiB
Python
#!/usr/bin/env python3
|
||
"""Render a publish-safe GitHub release body from the current RC packet."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
|
||
class ReleaseBodyIntegrityError(ValueError):
|
||
"""Raised when release-note Markdown is not safe to publish."""
|
||
|
||
|
||
_VALIDATION_STATUS_BLOCK_RE = re.compile(
|
||
r"(?:^|\n)<!-- VALIDATION_STATUS_START -->.*?"
|
||
r"<!-- VALIDATION_STATUS_END -->(?:\n{0,2}|$)",
|
||
re.DOTALL,
|
||
)
|
||
|
||
_HIGHLIGHTS_HEADING_RE = re.compile(r"^(#{2,6})[ \t]+Highlights[ \t]*$", re.IGNORECASE)
|
||
_HIGHLIGHT_BULLET_RE = re.compile(r"^-[ \t]+(.+)$")
|
||
_CUSTOMER_SECTION_HEADINGS = {
|
||
"what's improved",
|
||
"what’s improved",
|
||
"fixes",
|
||
"before you upgrade",
|
||
"known issues",
|
||
}
|
||
_CUSTOMER_CHANGE_GROUPS = {
|
||
"alerts and notifications", "disks and storage",
|
||
"proxmox, pbs and backups", "truenas, vsphere and docker",
|
||
"install, updates and agents", "updates and agents",
|
||
"pulse pro, ai and hosted", "monitoring and service health",
|
||
"security", "other improvements",
|
||
}
|
||
_INTERNAL_RELEASE_LANGUAGE_RE = re.compile(
|
||
r"\b(?:"
|
||
r"readiness assertions?"
|
||
r"|release gates?"
|
||
r"|candidate cutoff"
|
||
r"|exact-sha"
|
||
r"|immutable (?:release )?candidate"
|
||
r"|promotion channel"
|
||
r"|completion state"
|
||
r"|lane follow-?ups?"
|
||
r"|artifact identity"
|
||
r"|self-contained checks"
|
||
r")\b",
|
||
re.IGNORECASE,
|
||
)
|
||
_CUSTOMER_FORMAT_MINIMUM = (6, 4, 0)
|
||
_CUSTOMER_FORMAT_EXEMPTIONS = {"6.4.0-rc.1"}
|
||
_ISSUE_REFERENCE_RE = re.compile(
|
||
r"(?:"
|
||
r"(?<![A-Za-z0-9])#[0-9]+\b"
|
||
r"|\b[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+#[0-9]+\b"
|
||
r"|\bGH-[0-9]+\b"
|
||
r"|https?://(?:www\.)?github\.com/[^/\s]+/[^/\s]+/(?:issues|pull)/[0-9]+\b"
|
||
r")",
|
||
re.IGNORECASE,
|
||
)
|
||
_MAX_HIGHLIGHT_ITEMS = 3
|
||
_MAX_HIGHLIGHT_LENGTH = 140
|
||
_MAX_CUSTOMER_FIX_ITEMS = 12
|
||
_MAX_CUSTOMER_ITEM_LENGTH = 260
|
||
_MAX_CUSTOMER_SUMMARY_LENGTH = 600
|
||
|
||
|
||
def _normalize_newlines(text: str) -> str:
|
||
return text.replace("\r\n", "\n").replace("\r", "\n")
|
||
|
||
|
||
def _canonical_body(text: str) -> str:
|
||
return _normalize_newlines(text).rstrip("\n") + "\n"
|
||
|
||
|
||
def _find_inline_markdown_markers(text: str) -> list[str]:
|
||
markers: list[str] = []
|
||
for line_number, line in enumerate(_normalize_newlines(text).splitlines(), start=1):
|
||
heading_markers = re.finditer(r"#{2,6}[ \t]+\S", line)
|
||
if any(match.start() != 0 for match in heading_markers):
|
||
markers.append(f"inline heading marker on line {line_number}")
|
||
fence_markers = re.finditer(r"```(?:[A-Za-z0-9_-]+)?", line)
|
||
if any(match.start() != 0 for match in fence_markers):
|
||
markers.append(f"inline code-fence marker on line {line_number}")
|
||
return markers
|
||
|
||
|
||
def _highlight_items(text: str) -> list[str] | None:
|
||
"""Return the optional in-app Highlights list after validating its shape."""
|
||
|
||
lines = _normalize_newlines(text).splitlines()
|
||
headings: list[tuple[int, int]] = []
|
||
for index, line in enumerate(lines):
|
||
match = _HIGHLIGHTS_HEADING_RE.fullmatch(line.strip())
|
||
if match:
|
||
headings.append((index, len(match.group(1))))
|
||
|
||
if not headings:
|
||
return None
|
||
if len(headings) > 1:
|
||
raise ReleaseBodyIntegrityError(
|
||
"release notes must contain at most one Highlights section"
|
||
)
|
||
|
||
start_index, start_level = headings[0]
|
||
section_lines: list[str] = []
|
||
for line in lines[start_index + 1 :]:
|
||
heading = re.fullmatch(r"(#{1,6})[ \t]+\S.*", line.strip())
|
||
if heading and len(heading.group(1)) <= start_level:
|
||
break
|
||
section_lines.append(line)
|
||
|
||
items: list[str] = []
|
||
for line in section_lines:
|
||
if not line.strip():
|
||
continue
|
||
bullet = _HIGHLIGHT_BULLET_RE.fullmatch(line)
|
||
if bullet:
|
||
items.append(bullet.group(1).strip())
|
||
continue
|
||
if line[:1].isspace() and items and not line.lstrip().startswith(("- ", "* ", "+ ")):
|
||
items[-1] = f"{items[-1]} {line.strip()}"
|
||
continue
|
||
raise ReleaseBodyIntegrityError(
|
||
"Highlights must be a flat list of short plain-text bullets"
|
||
)
|
||
|
||
if not items:
|
||
raise ReleaseBodyIntegrityError("Highlights must contain at least one bullet")
|
||
if len(items) > _MAX_HIGHLIGHT_ITEMS:
|
||
raise ReleaseBodyIntegrityError(
|
||
f"Highlights may contain at most {_MAX_HIGHLIGHT_ITEMS} bullets"
|
||
)
|
||
|
||
for item in items:
|
||
if len(item) > _MAX_HIGHLIGHT_LENGTH:
|
||
raise ReleaseBodyIntegrityError(
|
||
"each Highlights bullet must be "
|
||
f"{_MAX_HIGHLIGHT_LENGTH} characters or fewer"
|
||
)
|
||
if (
|
||
"`" in item
|
||
or "*" in item
|
||
or "_" in item
|
||
or re.search(r"!?\[[^\]]+\]\([^\)]+\)", item)
|
||
or re.search(r"<[^>]+>", item)
|
||
or _ISSUE_REFERENCE_RE.search(item)
|
||
):
|
||
raise ReleaseBodyIntegrityError(
|
||
"Highlights bullets must use plain text without links, code, HTML, "
|
||
"or issue references"
|
||
)
|
||
|
||
return items
|
||
|
||
|
||
def _release_core(version: str) -> tuple[int, int, int] | None:
|
||
match = re.fullmatch(
|
||
r"v?(\d+)\.(\d+)\.(\d+)(?:-(?:rc|alpha|beta)\.\d+)?",
|
||
version,
|
||
re.IGNORECASE,
|
||
)
|
||
if not match:
|
||
return None
|
||
return tuple(int(match.group(index)) for index in range(1, 4))
|
||
|
||
|
||
def _requires_customer_facing_standard(version: str) -> bool:
|
||
normalized = version.lower().removeprefix("v")
|
||
if normalized in _CUSTOMER_FORMAT_EXEMPTIONS:
|
||
return False
|
||
core = _release_core(normalized)
|
||
return core is not None and core >= _CUSTOMER_FORMAT_MINIMUM
|
||
|
||
|
||
def _requires_single_change_list(version: str) -> bool:
|
||
"""Return whether fixes must be folded into the one customer outcome list."""
|
||
|
||
normalized = version.lower().removeprefix("v")
|
||
core = _release_core(normalized)
|
||
if core is None or core < (6, 4, 0):
|
||
return False
|
||
if core > (6, 4, 0):
|
||
return True
|
||
|
||
rc_match = re.fullmatch(r"6\.4\.0-rc\.(\d+)", normalized)
|
||
return rc_match is None or int(rc_match.group(1)) >= 6
|
||
|
||
|
||
def _requires_plain_release_punctuation(version: str) -> bool:
|
||
"""Apply the punctuation rule after the already-published v6.4 RC packets."""
|
||
|
||
normalized = version.lower().removeprefix("v")
|
||
core = _release_core(normalized)
|
||
if core is None or core < (6, 4, 0):
|
||
return False
|
||
if core > (6, 4, 0):
|
||
return True
|
||
rc_match = re.fullmatch(r"6\.4\.0-rc\.(\d+)", normalized)
|
||
return rc_match is None or int(rc_match.group(1)) >= 7
|
||
|
||
|
||
def _section_lines(text: str, heading_index: int) -> list[str]:
|
||
lines = _normalize_newlines(text).splitlines()
|
||
section: list[str] = []
|
||
for line in lines[heading_index + 1 :]:
|
||
if re.fullmatch(r"##[ \t]+\S.*", line):
|
||
break
|
||
section.append(line)
|
||
return section
|
||
|
||
|
||
def _flat_bullet_items(lines: list[str], section_name: str) -> list[str]:
|
||
items: list[str] = []
|
||
for line in lines:
|
||
if not line.strip():
|
||
continue
|
||
bullet = _HIGHLIGHT_BULLET_RE.fullmatch(line)
|
||
if bullet:
|
||
items.append(bullet.group(1).strip())
|
||
continue
|
||
if line[:1].isspace() and items and not line.lstrip().startswith(("- ", "* ", "+ ")):
|
||
items[-1] = f"{items[-1]} {line.strip()}"
|
||
continue
|
||
raise ReleaseBodyIntegrityError(
|
||
f"{section_name} must be a flat Markdown bullet list"
|
||
)
|
||
return items
|
||
|
||
|
||
def _validate_customer_facing_release_notes(text: str, version: str) -> None:
|
||
"""Enforce concise public notes without release-control implementation prose."""
|
||
|
||
lines = _normalize_newlines(text).strip().splitlines()
|
||
if _requires_plain_release_punctuation(version) and any(
|
||
character in text for character in (";", "—")
|
||
):
|
||
raise ReleaseBodyIntegrityError(
|
||
"customer-facing release notes must not contain semicolons or em dashes"
|
||
)
|
||
first_section_index = next(
|
||
(index for index, line in enumerate(lines) if re.fullmatch(r"##[ \t]+\S.*", line)),
|
||
None,
|
||
)
|
||
if first_section_index is None:
|
||
raise ReleaseBodyIntegrityError("customer-facing release notes need sections")
|
||
|
||
summary = " ".join(line.strip() for line in lines[1:first_section_index] if line.strip())
|
||
if not summary or summary.startswith(('-', '*', '+')):
|
||
raise ReleaseBodyIntegrityError(
|
||
"customer-facing release notes need one plain-language summary paragraph"
|
||
)
|
||
if len(summary) > _MAX_CUSTOMER_SUMMARY_LENGTH:
|
||
raise ReleaseBodyIntegrityError(
|
||
f"the release summary must be {_MAX_CUSTOMER_SUMMARY_LENGTH} characters or fewer"
|
||
)
|
||
|
||
# New packets follow the grouped, plain-language release story. Retain the
|
||
# historical validator for older packets without rewriting published prose.
|
||
grouped = (_release_core(version) or (0, 0, 0)) >= (6, 4, 6)
|
||
allowed_headings = _CUSTOMER_SECTION_HEADINGS | (
|
||
_CUSTOMER_CHANGE_GROUPS | {"highlights"} if grouped else set()
|
||
)
|
||
headings: dict[str, int] = {}
|
||
for index, line in enumerate(lines):
|
||
heading = re.fullmatch(r"##[ \t]+(.+?)\s*", line)
|
||
if not heading:
|
||
continue
|
||
normalized = re.sub(r"\s+", " ", heading.group(1)).lower()
|
||
if normalized not in allowed_headings:
|
||
raise ReleaseBodyIntegrityError(
|
||
"customer-facing release notes may only use What's improved, "
|
||
"supported customer change groups, Highlights, Before you upgrade, "
|
||
"and Known issues sections"
|
||
)
|
||
if normalized in headings:
|
||
raise ReleaseBodyIntegrityError(
|
||
f"customer-facing release notes contain duplicate {heading.group(1)} sections"
|
||
)
|
||
headings[normalized] = index
|
||
|
||
improvements_key = next(
|
||
(key for key in ("what's improved", "what’s improved") if key in headings),
|
||
None,
|
||
)
|
||
change_keys = [key for key in headings if key in _CUSTOMER_CHANGE_GROUPS] if grouped else []
|
||
if improvements_key:
|
||
change_keys.append(improvements_key)
|
||
if not change_keys:
|
||
raise ReleaseBodyIntegrityError(
|
||
"customer-facing release notes must contain a What's improved section "
|
||
"or a supported customer change group"
|
||
)
|
||
|
||
seen_changes: set[str] = set()
|
||
for key in change_keys:
|
||
improvements = _flat_bullet_items(_section_lines(text, headings[key]), key)
|
||
if not improvements:
|
||
raise ReleaseBodyIntegrityError(
|
||
f"{key} must contain at least one bullet"
|
||
)
|
||
for item in improvements:
|
||
if len(item) > _MAX_CUSTOMER_ITEM_LENGTH:
|
||
raise ReleaseBodyIntegrityError(
|
||
f"customer-facing bullets must be {_MAX_CUSTOMER_ITEM_LENGTH} characters or fewer"
|
||
)
|
||
if not grouped and not re.match(r"^\*\*[^*]+\*\*[ \t]+(?:—|-)[ \t]+\S", item):
|
||
raise ReleaseBodyIntegrityError(
|
||
"What's improved bullets must start with a short bold outcome followed by a dash"
|
||
)
|
||
normalized_item = re.sub(r"\s+", " ", item).casefold()
|
||
if grouped and normalized_item in seen_changes:
|
||
raise ReleaseBodyIntegrityError("customer change groups must not repeat a change")
|
||
seen_changes.add(normalized_item)
|
||
|
||
if "fixes" in headings:
|
||
if _requires_single_change_list(version):
|
||
raise ReleaseBodyIntegrityError(
|
||
"customer-facing release notes must describe features and fixes once "
|
||
"in What's improved instead of adding a separate Fixes section"
|
||
)
|
||
fixes = _flat_bullet_items(_section_lines(text, headings["fixes"]), "Fixes")
|
||
if not fixes or len(fixes) > _MAX_CUSTOMER_FIX_ITEMS:
|
||
raise ReleaseBodyIntegrityError(
|
||
f"Fixes must contain between 1 and {_MAX_CUSTOMER_FIX_ITEMS} bullets"
|
||
)
|
||
if any(len(item) > _MAX_CUSTOMER_ITEM_LENGTH for item in fixes):
|
||
raise ReleaseBodyIntegrityError(
|
||
f"customer-facing bullets must be {_MAX_CUSTOMER_ITEM_LENGTH} characters or fewer"
|
||
)
|
||
|
||
if _INTERNAL_RELEASE_LANGUAGE_RE.search(text):
|
||
raise ReleaseBodyIntegrityError(
|
||
"customer-facing release notes contain internal release-control language"
|
||
)
|
||
|
||
|
||
def validate_release_notes_shape(raw_text: str, version: str) -> None:
|
||
"""Fail closed when authored release-note Markdown has lost its structure."""
|
||
|
||
text = _normalize_newlines(raw_text).strip()
|
||
if not text:
|
||
raise ReleaseBodyIntegrityError("release notes are empty")
|
||
|
||
lines = text.splitlines()
|
||
expected_title = re.compile(
|
||
rf"^# Pulse v{re.escape(version)} (?:Draft )?Release Notes$"
|
||
)
|
||
if not expected_title.fullmatch(lines[0]):
|
||
raise ReleaseBodyIntegrityError(
|
||
"the first line must be the standalone release title "
|
||
f"'# Pulse v{version} Release Notes' (or its Draft form)"
|
||
)
|
||
|
||
level_two_headings = [
|
||
line for line in lines if re.fullmatch(r"##[ \t]+\S.*", line)
|
||
]
|
||
if not level_two_headings:
|
||
raise ReleaseBodyIntegrityError(
|
||
"release notes must contain at least one standalone level-two section"
|
||
)
|
||
|
||
inline_markers = _find_inline_markdown_markers(text)
|
||
if inline_markers:
|
||
raise ReleaseBodyIntegrityError(
|
||
"release notes contain flattened Markdown: " + ", ".join(inline_markers)
|
||
)
|
||
|
||
_highlight_items(text)
|
||
if _requires_customer_facing_standard(version):
|
||
_validate_customer_facing_release_notes(text, version)
|
||
|
||
|
||
def strip_validation_status_block(text: str) -> str:
|
||
"""Remove the workflow-owned validation annotation from a release body."""
|
||
|
||
stripped = _VALIDATION_STATUS_BLOCK_RE.sub("\n", _normalize_newlines(text), count=1)
|
||
return _canonical_body(stripped.lstrip("\n"))
|
||
|
||
|
||
def validate_server_updater_guidance(text: str) -> None:
|
||
"""Every executable updater example needs its own nearby ownership scope.
|
||
|
||
A warning in a distant highlight does not make an unqualified install or
|
||
rollback command safe. This covers authored bullets as well as the generated
|
||
sections, without rewriting the author's instructions or rollback target.
|
||
"""
|
||
command = re.compile(r"(?<![\w/])/bin/update\s+--version\b")
|
||
ownership = re.compile(
|
||
r"only when\s+`?/bin/update`?\s+was installed by\s+the Pulse server installer",
|
||
re.IGNORECASE,
|
||
)
|
||
for section in re.split(r"(?m)^#{1,6}[ \t]+.*$", _normalize_newlines(text)):
|
||
for match in command.finditer(section):
|
||
paragraphs = re.split(r"\n[ \t]*\n", section[:match.start()])
|
||
context = paragraphs[-1]
|
||
# The generated command is in a fence, immediately after the scoped
|
||
# paragraph. Do not borrow a warning from an earlier bullet/section.
|
||
if re.fullmatch(r"\s*```[A-Za-z0-9_-]*\s*(?:sudo\s+)?", context):
|
||
context = paragraphs[-2] if len(paragraphs) > 1 else ""
|
||
if not ownership.search(context):
|
||
raise ReleaseBodyIntegrityError(
|
||
"each /bin/update --version command must say nearby that it is "
|
||
"only for the helper installed by the Pulse server installer"
|
||
)
|
||
|
||
|
||
def validate_release_body_shape(
|
||
body: str,
|
||
version: str,
|
||
*,
|
||
expected_body: str | None = None,
|
||
) -> str:
|
||
"""Validate a stored GitHub release body and return its authored body."""
|
||
|
||
clean_body = strip_validation_status_block(body)
|
||
|
||
if clean_body.count("## Install\n") != 1:
|
||
raise ReleaseBodyIntegrityError(
|
||
"published release body must contain exactly one Install section"
|
||
)
|
||
if clean_body.count("## Roll back\n") != 1:
|
||
raise ReleaseBodyIntegrityError(
|
||
"published release body must contain exactly one Roll back section"
|
||
)
|
||
if "## Promotion Metadata\n" in clean_body:
|
||
raise ReleaseBodyIntegrityError(
|
||
"published release body must keep promotion metadata out of customer notes"
|
||
)
|
||
if "Draft Release Notes" in clean_body or "_DRAFT.md" in clean_body:
|
||
raise ReleaseBodyIntegrityError(
|
||
"published release body still contains draft-only framing"
|
||
)
|
||
|
||
installation_index = clean_body.index("## Install\n")
|
||
rollback_index = clean_body.index("## Roll back\n")
|
||
if installation_index >= rollback_index:
|
||
raise ReleaseBodyIntegrityError(
|
||
"Install must precede Roll back in the published body"
|
||
)
|
||
|
||
authored_prefix = clean_body[:installation_index]
|
||
inline_markers = _find_inline_markdown_markers(authored_prefix)
|
||
if inline_markers:
|
||
raise ReleaseBodyIntegrityError(
|
||
"release notes contain flattened Markdown: " + ", ".join(inline_markers)
|
||
)
|
||
authored_sections = re.findall(r"(?m)^##[ \t]+\S.*$", authored_prefix)
|
||
if not authored_sections:
|
||
raise ReleaseBodyIntegrityError(
|
||
"published release body has no authored section before Install"
|
||
)
|
||
visual_heading = "\n## See the difference\n"
|
||
if authored_prefix.count(visual_heading) > 1:
|
||
raise ReleaseBodyIntegrityError(
|
||
"published release body must contain at most one See the difference section"
|
||
)
|
||
authored_notes = authored_prefix.split(visual_heading, 1)[0]
|
||
validate_release_notes_shape(authored_notes, version)
|
||
validate_server_updater_guidance(clean_body)
|
||
|
||
if expected_body is not None:
|
||
expected_clean = strip_validation_status_block(expected_body)
|
||
if clean_body != expected_clean:
|
||
raise ReleaseBodyIntegrityError(
|
||
"GitHub's stored release body does not exactly match the expected "
|
||
"rendered Markdown"
|
||
)
|
||
|
||
return clean_body
|
||
|
||
|
||
def _replace_draft_heading(text: str, version: str) -> str:
|
||
lines = text.splitlines()
|
||
for index, line in enumerate(lines):
|
||
if line.startswith("# ") and "Draft Release Notes" in line:
|
||
lines[index] = f"# Pulse v{version} Release Notes"
|
||
return "\n".join(lines)
|
||
return text
|
||
|
||
|
||
def _drop_draft_disclaimer(text: str) -> str:
|
||
pattern = re.compile(
|
||
r"(?:^|\n)_?Draft only\. Do not treat this as published.*?(?:\n{2,}|$)",
|
||
re.DOTALL,
|
||
)
|
||
return pattern.sub("\n\n", text, count=1)
|
||
|
||
|
||
def _drop_level_two_sections(text: str, headings: set[str]) -> str:
|
||
lines = text.splitlines()
|
||
kept: list[str] = []
|
||
skip = False
|
||
|
||
for line in lines:
|
||
stripped = line.strip()
|
||
if stripped in headings:
|
||
skip = True
|
||
continue
|
||
if skip and stripped.startswith("## "):
|
||
skip = False
|
||
if not skip:
|
||
kept.append(line)
|
||
|
||
return "\n".join(kept)
|
||
|
||
|
||
def _drop_draft_packet_links(text: str) -> str:
|
||
return "\n".join(line for line in text.splitlines() if "_DRAFT.md" not in line)
|
||
|
||
|
||
def _collapse_blank_lines(text: str) -> str:
|
||
text = _normalize_newlines(text).strip()
|
||
text = re.sub(r"\n{3,}", "\n\n", text)
|
||
return text + "\n"
|
||
|
||
|
||
def sanitize_release_notes(raw_text: str, version: str) -> str:
|
||
text = _normalize_newlines(raw_text)
|
||
text = _replace_draft_heading(text, version)
|
||
text = _drop_draft_disclaimer(text)
|
||
text = _drop_level_two_sections(
|
||
text,
|
||
{"## Installation", "## Install", "## Roll back", "## Promotion Metadata"},
|
||
)
|
||
text = _drop_draft_packet_links(text)
|
||
return _collapse_blank_lines(text)
|
||
|
||
|
||
def server_installer_fallback(target: str, docs_version: str) -> str:
|
||
return (
|
||
"On Proxmox community-scripts containers, or if you cannot confirm who installed "
|
||
"`/bin/update`, follow the "
|
||
f"[signed server-installer instructions](https://github.com/rcourtman/Pulse/blob/v{docs_version}/docs/INSTALL.md#2-bare-metal--systemd) "
|
||
f"with `PULSE_VERSION={target}`. The community-scripts updater can ignore `--version`."
|
||
)
|
||
|
||
|
||
def build_installation_section(version: str) -> str:
|
||
return "\n".join(
|
||
[
|
||
"## Install",
|
||
"",
|
||
"For systemd and Proxmox LXC server updates, use **Settings → System → Updates**. "
|
||
"Use this CLI command only when `/bin/update` was installed by the Pulse server installer:",
|
||
"",
|
||
"```bash",
|
||
f"sudo /bin/update --version v{version}",
|
||
"```",
|
||
"",
|
||
server_installer_fallback(f"v{version}", version),
|
||
"",
|
||
"For Docker:",
|
||
"",
|
||
"```bash",
|
||
f"docker pull rcourtman/pulse:{version}",
|
||
"```",
|
||
"",
|
||
f"For Docker Compose, update the image to `rcourtman/pulse:{version}` and recreate the container.",
|
||
"",
|
||
"Pulse Pro and Relay customers should continue using the "
|
||
"[private download page](https://pulserelay.pro/download.html) and private "
|
||
"runtime image for paid features.",
|
||
]
|
||
)
|
||
|
||
|
||
def build_rollback_section(args: argparse.Namespace) -> str:
|
||
return "\n".join(
|
||
[
|
||
"## Roll back",
|
||
"",
|
||
f"The rollback target is `{args.rollback_target}`:",
|
||
"",
|
||
"For systemd and Proxmox LXC servers, use this command only when "
|
||
"`/bin/update` was installed by the Pulse server installer:",
|
||
"",
|
||
"```bash",
|
||
args.rollback_command,
|
||
"```",
|
||
"",
|
||
server_installer_fallback(args.rollback_target, args.version),
|
||
"",
|
||
"For Docker Compose, set the Pulse image to the rollback target and recreate the container.",
|
||
]
|
||
)
|
||
|
||
|
||
# ASCII digits only, bounded, and matched against the whole string: `$` would accept a final newline
|
||
# (which would land inside the URL), `\d` accepts non-ASCII digits, and an unbounded number would make
|
||
# the comparison below raise on conversion.
|
||
_COMPARISON_HEAD = re.compile(r"v([0-9]{1,6})\.([0-9]{1,6})\.([0-9]{1,6})(?:-(?:alpha|beta|rc)\.[1-9][0-9]{0,5})?")
|
||
_COMPARISON_BASE = re.compile(r"v([0-9]{1,6})\.([0-9]{1,6})\.([0-9]{1,6})")
|
||
|
||
|
||
def build_source_comparison(args: argparse.Namespace) -> str | None:
|
||
"""A pointer to GitHub's own comparison of this release with the stable rollback target it was given.
|
||
|
||
This is a pointer, not a complete list: GitHub shows at most 250 commits in a comparison, so a
|
||
release with more changes than that is only partly visible through it. It is also advisory. It
|
||
exists so a reader can look up a change the summary does not name, and it renders nothing, and
|
||
raises nothing, for a missing or malformed tag or for a base that is not a lower stable release.
|
||
"""
|
||
head = _COMPARISON_HEAD.fullmatch(args.candidate_tag or "")
|
||
base = _COMPARISON_BASE.fullmatch(args.rollback_target or "")
|
||
if not head or not base:
|
||
return None
|
||
if tuple(map(int, base.groups())) >= tuple(map(int, head.groups())):
|
||
return None
|
||
return "\n".join(
|
||
[
|
||
"## Source comparison",
|
||
"",
|
||
f"[Browse source changes from `{args.rollback_target}` to `{args.candidate_tag}`]"
|
||
f"(https://github.com/rcourtman/Pulse/compare/{args.rollback_target}...{args.candidate_tag}) "
|
||
"on GitHub for more detail behind this summary.",
|
||
"",
|
||
"This comparison includes internal maintenance. GitHub shows at most 250 commits, "
|
||
"so larger comparisons may omit commits.",
|
||
]
|
||
)
|
||
|
||
|
||
def parse_args() -> argparse.Namespace:
|
||
parser = argparse.ArgumentParser(description=__doc__)
|
||
parser.add_argument("--version", required=True)
|
||
parser.add_argument("--release-notes-file")
|
||
parser.add_argument("--release-visuals-file")
|
||
parser.add_argument("--validate-notes-file")
|
||
parser.add_argument("--validate-body-file")
|
||
parser.add_argument("--expected-body-file")
|
||
parser.add_argument("--output")
|
||
parser.add_argument("--promotion-channel")
|
||
parser.add_argument("--candidate-tag")
|
||
parser.add_argument("--promoted-prerelease-tag", default="")
|
||
parser.add_argument("--rollback-target")
|
||
parser.add_argument("--rollback-command")
|
||
parser.add_argument("--planned-ga-date", default="")
|
||
parser.add_argument("--planned-v5-eos-date", default="")
|
||
parser.add_argument("--hotfix-exception")
|
||
parser.add_argument("--hotfix-reason", default="")
|
||
parser.add_argument("--require-windows-signing")
|
||
parser.add_argument("--unsigned-windows-exception")
|
||
parser.add_argument("--unsigned-windows-reason", default="")
|
||
args = parser.parse_args()
|
||
|
||
if args.validate_notes_file or args.validate_body_file:
|
||
if args.release_notes_file:
|
||
parser.error(
|
||
"validation modes cannot be combined with --release-notes-file"
|
||
)
|
||
if args.validate_notes_file and args.validate_body_file:
|
||
parser.error(
|
||
"--validate-notes-file cannot be combined with --validate-body-file"
|
||
)
|
||
if args.validate_notes_file and (args.expected_body_file or args.output):
|
||
parser.error(
|
||
"--validate-notes-file cannot use --expected-body-file or --output"
|
||
)
|
||
return args
|
||
|
||
required_render_args = {
|
||
"--release-notes-file": args.release_notes_file,
|
||
"--output": args.output,
|
||
"--promotion-channel": args.promotion_channel,
|
||
"--candidate-tag": args.candidate_tag,
|
||
"--rollback-target": args.rollback_target,
|
||
"--rollback-command": args.rollback_command,
|
||
"--hotfix-exception": args.hotfix_exception,
|
||
"--require-windows-signing": args.require_windows_signing,
|
||
"--unsigned-windows-exception": args.unsigned_windows_exception,
|
||
}
|
||
missing = [name for name, value in required_render_args.items() if value is None]
|
||
if missing:
|
||
parser.error("render mode requires " + ", ".join(missing))
|
||
if args.expected_body_file:
|
||
parser.error("--expected-body-file requires --validate-body-file")
|
||
return args
|
||
|
||
|
||
def main() -> int:
|
||
args = parse_args()
|
||
try:
|
||
if args.validate_notes_file:
|
||
raw_text = Path(args.validate_notes_file).read_text(encoding="utf-8")
|
||
validate_release_notes_shape(raw_text, args.version)
|
||
return 0
|
||
|
||
if args.validate_body_file:
|
||
body = Path(args.validate_body_file).read_text(encoding="utf-8")
|
||
expected_body = None
|
||
if args.expected_body_file:
|
||
expected_body = Path(args.expected_body_file).read_text(encoding="utf-8")
|
||
clean_body = validate_release_body_shape(
|
||
body,
|
||
args.version,
|
||
expected_body=expected_body,
|
||
)
|
||
if args.output:
|
||
Path(args.output).write_text(clean_body, encoding="utf-8")
|
||
return 0
|
||
|
||
raw_text = Path(args.release_notes_file).read_text(encoding="utf-8")
|
||
validate_release_notes_shape(raw_text, args.version)
|
||
sanitized = sanitize_release_notes(raw_text, args.version).rstrip("\n")
|
||
sections = [sanitized]
|
||
if args.release_visuals_file:
|
||
release_visuals = Path(args.release_visuals_file).read_text(
|
||
encoding="utf-8"
|
||
).strip()
|
||
if release_visuals:
|
||
if not release_visuals.startswith("## See the difference\n"):
|
||
raise ReleaseBodyIntegrityError(
|
||
"release visuals must begin with '## See the difference'"
|
||
)
|
||
sections.append(release_visuals)
|
||
sections.extend(
|
||
[
|
||
build_installation_section(args.version),
|
||
build_rollback_section(args),
|
||
]
|
||
)
|
||
comparison = build_source_comparison(args)
|
||
if comparison:
|
||
sections.append(comparison)
|
||
rendered = "\n\n".join(sections) + "\n"
|
||
validate_release_body_shape(rendered, args.version)
|
||
Path(args.output).write_text(rendered, encoding="utf-8")
|
||
return 0
|
||
except ReleaseBodyIntegrityError as exc:
|
||
print(f"release body integrity check failed: {exc}", file=sys.stderr)
|
||
return 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|