mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-02 20:29:43 +00:00
Keep authored release notes compatible with safety checks
Bind stable notes to version and operator disclosures rather than obsolete prose. Support bounded grouped customer sections for v6.4.6 and later, align generation and rendering, and preserve published history and all qualification gates. Change-source: pulse-maintainer
This commit is contained in:
parent
78f2b7c032
commit
022b55083d
9 changed files with 442 additions and 31 deletions
|
|
@ -1021,9 +1021,16 @@ Companion drill:
|
|||
`.github/scripts/check-demo-reachability.sh`
|
||||
`scripts/trigger-stable-patch.sh`
|
||||
- Automated proof:
|
||||
`go test ./scripts/installtests -run 'TestStablePatchReleaseNotes' -count=1`
|
||||
`cd scripts/release_control && python3 -m unittest render_release_body_test`
|
||||
`cd scripts/release_control && python3 -m unittest resolve_release_promotion_test release_promotion_policy_test`
|
||||
`go test ./scripts/installtests -run 'Test(Demo|DeployDemo|UpdateDemo|Release)' -count=1`
|
||||
- Manual scenario:
|
||||
- Compare the exact selected release notes with the install-metadata verdict.
|
||||
Authored grouped notes must retain version identity, unsigned-Windows and
|
||||
publisher warning, mobile compatibility and the exact stable rollback. A
|
||||
wording mismatch is not a product-runtime diagnosis. Do not mutate a shipped
|
||||
release or skip functional checks to obtain a passing watchdog.
|
||||
1. Push the exact candidate commit to the governed stable branch.
|
||||
2. For a no-public-release rehearsal, dispatch
|
||||
`./scripts/trigger-stable-patch.sh --dry-run <version>`.
|
||||
|
|
|
|||
|
|
@ -1922,9 +1922,19 @@ artifact-selection behaviour.
|
|||
duplicate appended `Installation` / `Promotion Metadata` sections verbatim.
|
||||
From the release after `v6.4.0-rc.1` onward, that renderer also owns a
|
||||
customer-facing communication contract. Public notes lead with one short
|
||||
outcome paragraph, use a scannable `What's improved` section with no more
|
||||
than six concrete items, keep fixes symptom-led, and reserve `Before you
|
||||
outcome paragraph, use scannable symptom-led changes, and reserve `Before you
|
||||
upgrade` or `Known issues` for information users must act on or understand.
|
||||
From v6.4.6 onward, the authoring tools and renderer accept a short plain-text
|
||||
`Highlights` list and grouped customer sections (alerts, storage, PBS, NAS,
|
||||
updates, Pro, service health, security or other improvements). A narrow patch
|
||||
may retain `What's improved`. Groups must be non-empty and not duplicate the
|
||||
same change, bullets remain bounded, and internal status headings stay out.
|
||||
The install-metadata checks bind the current version in the notes title and
|
||||
retain Windows signing/warning, companion compatibility and exact stable
|
||||
rollback disclosures. Stable maturity and promotion lineage remain bound by
|
||||
the changelog and release metadata, not a mandatory public boilerplate
|
||||
sentence. Historical published notes are not edited or re-rendered to fit a
|
||||
later authoring template.
|
||||
Qualification counts, readiness assertions, release gates, workflow
|
||||
narration, artifact identity, and promotion metadata stay in governed
|
||||
workflow summaries and evidence records rather than the public changelog.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,22 @@
|
|||
One short paragraph explaining the customer outcome of the release. Lead with
|
||||
what feels better or works now, not how it was implemented.
|
||||
|
||||
## Highlights
|
||||
|
||||
- One to three short plain-text outcomes. Keep each under 140 characters with
|
||||
no Markdown, links or issue references so the in-app highlights stay readable.
|
||||
|
||||
## Proxmox, PBS and backups
|
||||
|
||||
- Explain a change in plain language, where users notice it and why it matters.
|
||||
|
||||
From v6.4.6 onward, group related changes under the relevant reader-facing
|
||||
headings: 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, and Other
|
||||
improvements. Omit unused groups. A narrow patch can keep the simpler section
|
||||
below instead. Do not repeat a change between groups or add empty sections.
|
||||
|
||||
## What's improved
|
||||
|
||||
- **Short outcome** - Explain where users notice it and why it matters.
|
||||
|
|
@ -12,7 +28,7 @@ what feels better or works now, not how it was implemented.
|
|||
|
||||
Use a concise set of meaningful improvements. A narrow patch may use fewer
|
||||
rather than padding the notes with internal work. Each user-visible change
|
||||
belongs in this list exactly once. Prefer observable behavior over component
|
||||
belongs in the grouped changes exactly once. Prefer observable behavior over component
|
||||
names, group related implementation work into one user-recognizable theme, and
|
||||
use plain language. Each complete bullet, including Markdown links, must be no
|
||||
more than 260 characters. New release notes must not contain semicolons or em
|
||||
|
|
@ -33,6 +49,12 @@ constraints, and optional per-pass traces.
|
|||
Do not add a separate `Fixes` section. That shape encourages the same change to
|
||||
be described twice as both an improvement and a fix.
|
||||
|
||||
## Known issues
|
||||
|
||||
Describe remaining user symptoms and an actionable workaround or next step.
|
||||
Omit this section when there are none. Do not substitute internal verification
|
||||
status for information users can act on.
|
||||
|
||||
## Before you upgrade
|
||||
|
||||
Include only compatibility, migration, signing, companion-app, known-risk, or
|
||||
|
|
|
|||
|
|
@ -176,6 +176,47 @@ links, must be 260 characters or fewer. Use no semicolon or em dash characters.]
|
|||
section when there is none.]
|
||||
EOF
|
||||
|
||||
# v6.4.6 and later use the current reader-facing format, not the old forced
|
||||
# outcome/dash list. The renderer still validates the exact version and safety.
|
||||
if python3 - "$VERSION" <<'PY'
|
||||
import re, sys
|
||||
match = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)(?:-(?:alpha|beta|rc)\.\d+)?", sys.argv[1])
|
||||
sys.exit(0 if match and tuple(map(int, match.groups())) >= (6, 4, 6) else 1)
|
||||
PY
|
||||
then
|
||||
read -r -d '' NOTE_FORMAT <<EOF || true
|
||||
# Pulse v${VERSION} Release Notes
|
||||
|
||||
[One short paragraph explaining the customer outcome of this release.]
|
||||
|
||||
## Highlights
|
||||
|
||||
- [One to three plain-text outcomes, each 140 characters or fewer. No markup,
|
||||
links or issue references in this short list.]
|
||||
|
||||
## Proxmox, PBS and backups
|
||||
|
||||
- [Plain-language change, where users notice it and why it matters.]
|
||||
|
||||
[Choose only relevant 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. A narrow patch may use What's improved
|
||||
instead. Do not create empty groups or repeat the same change between groups.
|
||||
Each full bullet must be 260 characters or fewer. No semicolons or em dashes.]
|
||||
|
||||
## Known issues
|
||||
|
||||
[Only unresolved user symptoms with an actionable workaround or next step.
|
||||
Omit this section when there are none.]
|
||||
|
||||
## Before you upgrade
|
||||
|
||||
[Only compatibility, signing, companion-app or required operator information.
|
||||
Omit this section when there is none. No internal verification status.]
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Strip accidental markdown fences and anything before the release title.
|
||||
clean_notes() {
|
||||
sed -e 's/^```[a-z]*$//' -e 's/^```$//' | \
|
||||
|
|
|
|||
|
|
@ -1147,15 +1147,15 @@ func TestCurrentStablePatchReleasePacketTracksInstallMetadata(t *testing.T) {
|
|||
releaseNotesPath := repoFile("docs", "releases", "RELEASE_NOTES_v"+version+".md")
|
||||
changelogPath := repoFile("docs", "releases", "V6_CHANGELOG_v"+version+".md")
|
||||
|
||||
assertFileContainsAllNormalized(t, releaseNotesPath,
|
||||
"`v"+version+"` is a stable patch release",
|
||||
"`v"+previous+"`",
|
||||
"## What's improved",
|
||||
"not Authenticode-signed",
|
||||
"Unknown Publisher warning",
|
||||
"does not require a companion mobile release",
|
||||
"rollback target is stable `v"+previous+"`",
|
||||
)
|
||||
// The changelog below binds stable maturity and promotion lineage. Public
|
||||
// notes bind the version and operator safety, not one author's boilerplate.
|
||||
notes, err := os.ReadFile(releaseNotesPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, issue := range stablePatchReleaseNotesIssues(string(notes), version, previous) {
|
||||
t.Errorf("%s: %s", releaseNotesPath, issue)
|
||||
}
|
||||
changelogRequired := make([]string, 0, 7)
|
||||
changelogRequired = append(changelogRequired,
|
||||
"Version: `v"+version+"`",
|
||||
|
|
|
|||
110
scripts/installtests/release_notes_contract_test.go
Normal file
110
scripts/installtests/release_notes_contract_test.go
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
package installtests
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// These are reader-facing change sections, not internal qualification headings.
|
||||
var releaseNoteChangeHeadings = map[string]bool{
|
||||
"what's improved": true, "what’s improved": true,
|
||||
"alerts and notifications": true, "disks and storage": true,
|
||||
"proxmox, pbs and backups": true, "truenas, vsphere and docker": true,
|
||||
"install, updates and agents": true, "updates and agents": true,
|
||||
"pulse pro, ai and hosted": true, "monitoring and service health": true,
|
||||
"security": true, "other improvements": true,
|
||||
}
|
||||
|
||||
func stablePatchReleaseNotesIssues(notes, version, previous string) []string {
|
||||
var issues []string
|
||||
lines := strings.Split(strings.TrimSpace(notes), "\n")
|
||||
title := regexp.MustCompile(`^# Pulse v` + regexp.QuoteMeta(version) + `(?: Release Notes)?$`)
|
||||
if !title.MatchString(strings.TrimSpace(lines[0])) {
|
||||
issues = append(issues, "release title must name the exact current version")
|
||||
}
|
||||
changesSection, hasChanges := false, false
|
||||
for _, line := range lines[1:] {
|
||||
line = strings.TrimSpace(line)
|
||||
if strings.HasPrefix(line, "## ") {
|
||||
changesSection = releaseNoteChangeHeadings[strings.ToLower(strings.TrimPrefix(line, "## "))]
|
||||
} else if strings.HasPrefix(line, "#") {
|
||||
changesSection = false
|
||||
} else if changesSection && strings.HasPrefix(line, "- ") && len(strings.TrimSpace(line[2:])) > 0 {
|
||||
hasChanges = true
|
||||
}
|
||||
}
|
||||
if !hasChanges {
|
||||
issues = append(issues, "notes need non-empty user-facing change bullets")
|
||||
}
|
||||
normalized := strings.ToLower(strings.Join(strings.Fields(notes), " "))
|
||||
for _, required := range []string{
|
||||
"not authenticode-signed", "unknown publisher warning",
|
||||
"rollback target is stable `v" + previous + "`",
|
||||
} {
|
||||
if !strings.Contains(normalized, required) {
|
||||
issues = append(issues, "missing operator safety disclosure: "+required)
|
||||
}
|
||||
}
|
||||
compatibleMobile := strings.Contains(normalized, "does not require a companion mobile release") ||
|
||||
strings.Contains(normalized, "pulse mobile works with this release unchanged") ||
|
||||
strings.Contains(normalized, "pulse mobile is being retired on 31 march 2027. it keeps working until then.")
|
||||
if !compatibleMobile {
|
||||
issues = append(issues, "missing companion mobile compatibility or approved retirement disclosure")
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
func TestStablePatchReleaseNotesAcceptAuthoredPublishedCopy(t *testing.T) {
|
||||
// Exact public source b50aeb6a8d9e38ab362b23f4ed15a010f675a90f.
|
||||
// A fixture is not permission to edit or re-render the published release.
|
||||
notes, err := os.ReadFile("testdata/release-notes-v6.4.5-authored.md")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if issues := stablePatchReleaseNotesIssues(string(notes), "6.4.5", "6.4.1"); len(issues) > 0 {
|
||||
t.Fatal(issues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStablePatchReleaseNotesKeepIdentityAndSafety(t *testing.T) {
|
||||
notes := "# Pulse v6.4.6 Release Notes\n\nPulse updates and History are clearer.\n\n" +
|
||||
"## What's improved\n\n- History stays with the current host.\n\n" +
|
||||
"## Before you upgrade\n\nWindows binaries are not Authenticode-signed. " +
|
||||
"Windows may show an Unknown Publisher warning. " +
|
||||
"This does not require a companion mobile release. " +
|
||||
"The rollback target is stable `v6.4.5`.\n"
|
||||
if issues := stablePatchReleaseNotesIssues(notes, "6.4.6", "6.4.5"); len(issues) > 0 {
|
||||
t.Fatal(issues)
|
||||
}
|
||||
for _, change := range []struct{ name, from, to string }{
|
||||
{"wrong title", "# Pulse v6.4.6", "# Pulse v6.4.5"},
|
||||
{"no changes", "- History stays with the current host.", ""},
|
||||
{"internal changes only", "## What's improved", "## Qualification"},
|
||||
{"signing omitted", "not Authenticode-signed", "signed"},
|
||||
{"publisher warning omitted", "Unknown Publisher warning", "warning"},
|
||||
{"mobile omitted", "does not require a companion mobile release", "requires a new mobile build"},
|
||||
{"wrong rollback", "rollback target is stable `v6.4.5`", "rollback target is stable `v6.4.1`"},
|
||||
} {
|
||||
t.Run(change.name, func(t *testing.T) {
|
||||
if issues := stablePatchReleaseNotesIssues(strings.ReplaceAll(notes, change.from, change.to), "6.4.6", "6.4.5"); len(issues) == 0 {
|
||||
t.Fatal("unsafe or mismatched notes accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
// Preserve the Markdown title/heading while exercising wrapped safety text.
|
||||
wrapped := strings.ReplaceAll(notes, "Windows binaries are", "Windows\nbinaries are")
|
||||
if issues := stablePatchReleaseNotesIssues(wrapped, "6.4.6", "6.4.5"); len(issues) > 0 {
|
||||
t.Fatal(issues)
|
||||
}
|
||||
for _, disclosure := range []string{
|
||||
"Pulse Mobile works with this release unchanged",
|
||||
"Pulse Mobile is being retired on 31 March 2027. It keeps working until then.",
|
||||
} {
|
||||
candidate := strings.ReplaceAll(notes, "This does not require a companion mobile release", disclosure)
|
||||
if issues := stablePatchReleaseNotesIssues(candidate, "6.4.6", "6.4.5"); len(issues) > 0 {
|
||||
t.Fatal(issues)
|
||||
}
|
||||
}
|
||||
}
|
||||
81
scripts/installtests/testdata/release-notes-v6.4.5-authored.md
vendored
Normal file
81
scripts/installtests/testdata/release-notes-v6.4.5-authored.md
vendored
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# Pulse v6.4.5
|
||||
|
||||
The first stable release since v6.4.1. It brings everyone the fixes that have been in the v6.4.5 previews over the past month: quieter and more accurate alerts, fewer disk writes, working updates and Windows agent installs, and better PBS, TrueNAS, vSphere and Docker support.
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Far fewer false disk-wear alerts.** Endurance counters no longer raise false disk-wear warnings, disk I/O totals are no longer inflated, and a recovered or excluded disk stops re-alerting (#2112).
|
||||
- **Stable updates work again.** The updater reads the full release information and offers the right build, including on ARMv6 and ARMv7 (#2282).
|
||||
- **Windows agent installs and auto-updates work again.** Downloads no longer fail with HTTP 404 (#1820, #2125).
|
||||
- **Machines with the same name stay separate.** Hosts, Docker, Kubernetes and Proxmox systems that share a short name or token no longer get their alerts, metrics or actions mixed up (#1753, #1930).
|
||||
- **Less writing to SSD and flash storage.** Pulse no longer rewrites unchanged alert state on every cycle (#1966).
|
||||
- **Large alert histories no longer freeze Pulse.** They load gradually instead of blocking the server, and a slow start now shows its status with a retry button (#2129, #2146).
|
||||
|
||||
## Alerts and notifications
|
||||
|
||||
- A problem that continues after its parent recovers now gets its own notification, and grouped alerts no longer repeat (#2336).
|
||||
- Many alerts recovering at once are grouped into one notification instead of flooding your notification queue (#2160).
|
||||
- Notifications due at startup wait until your webhook, email and Apprise settings have loaded, so they are no longer dropped (#2160).
|
||||
- A warning that becomes critical is now delivered, escalations respect current acknowledgements and snoozes, and an automatic acknowledgement survives the alert firing again (#1801, #2173).
|
||||
- Removed or suppressed resources stop creating alerts (#2237).
|
||||
- The Alerts overview counts now match the incidents shown, alert-card footers line up, and the Alert Thresholds list keeps every host visible while scrolling (#2119, #2130).
|
||||
|
||||
## Disks and storage
|
||||
|
||||
- The same USB disk seen by both Proxmox and the agent can now be shown once when one source lacks a stable ID, while ambiguous or conflicting disks stay separate (#2076).
|
||||
- VMs and containers show the physical disks, SMART and RAID details reported by their linked agent (#2263).
|
||||
- Long mount paths in the Filesystems panel are shown in full instead of being cut to identical prefixes (#2121).
|
||||
|
||||
## Proxmox, PBS and backups
|
||||
|
||||
- PBS host and config backups no longer trigger repeated guest backup-age alerts, and a guest backed up locally and copied to PBS is counted once (#1741, #1721, #2136).
|
||||
- PBS History keeps each host and datastore separate across refreshes (#1723).
|
||||
- PBS datastore health uses your warning and critical thresholds (#1448).
|
||||
- Proxmox LXC memory uses the reading from an online Pulse agent inside the container, which excludes the page cache, when the agent's total matches the container's limit (#2148).
|
||||
- The resource drawer stays on the tab you picked during live refreshes (#1723).
|
||||
- Your "Verify SSL Certificate" choice for a node or PBS is kept through agent re-registration and cluster changes (#2140).
|
||||
|
||||
## TrueNAS, vSphere and Docker
|
||||
|
||||
- TrueNAS: AMD temperatures are no longer inflated, TrueNAS CORE memory is read correctly, CPU and memory charts keep their data, and idle sessions poll less often (#2122, #2077, #1893).
|
||||
- vSphere: enrichment works again with vSphere 8.0.3 and shows the actual API error instead of HTTP 500 (#2070).
|
||||
- Docker: containers such as PostgreSQL no longer show a false "update available" (#2110).
|
||||
|
||||
## Install, updates and agents
|
||||
|
||||
- A failed update no longer leaves stale backups behind, and a successful one is no longer reported as failed (#2127, #2128).
|
||||
- FreeBSD and pfSense agent installs verify checksums without GNU tools, and the copied install command keeps its line breaks (#2123).
|
||||
- Agents that re-enroll with a changed identity heal cleanly without a burst of events, and a removed agent stays removed (#2113, #1586).
|
||||
- Upgrades from v5 keep your existing hosts (#1913).
|
||||
- The upgrade guide warns that on some Proxmox community-script containers `/bin/update` is the community-scripts updater, and shows the signed Pulse installer instead (#2129).
|
||||
- Previews stuck on the old broken self-updater may need one manual, version-pinned update (#2282).
|
||||
|
||||
## Pulse Pro, AI and hosted
|
||||
|
||||
- AI Patrol caches its system prompt, so repeated runs cost less (#2118), and a saved Patrol objective whose watcher was lost or rejected is restored instead of going unchecked (#2147).
|
||||
- AI knowledge saves no longer conflict when made quickly one after another.
|
||||
- Ollama Basic Auth, the configured-admin setting and security setup choices survive saving and restarting.
|
||||
- Provider (MSP) installs keep each client's networks separate and client workspaces healthy through upgrades, and agent install tokens are created in the right client workspace (#2247, #2226, #2209).
|
||||
- Organization owners can reach the settings their organization allows (#2208).
|
||||
|
||||
## Other improvements
|
||||
|
||||
- Availability views with 20 or more checks open in the fleet view, and your chosen table or fleet view stays put across refreshes and shared links.
|
||||
- The metrics store drops replayed samples and is more robust across shutdowns, rollups and upgrades.
|
||||
|
||||
## Known issues
|
||||
|
||||
- **PBS History for a replaced PBS host can reappear.** If a PBS host's identity changes, its old History may show again. No monitoring data is lost. The fix is planned for v6.4.6 (#2343).
|
||||
- **The update screen can stay on "Downloading update… 10%".** The update still completes in the background. Refresh the page after a minute to see the new version. A fix is planned for v6.4.6.
|
||||
- Some PBS History layouts that combine the API and an agent are still not fully resolved (#1723).
|
||||
- Disk writes are much lower but not yet at their final level on busy installs. Keep an eye on write activity if you run on flash storage, and some older duplicate incidents may remain (#1966).
|
||||
- A small number of v5 to v6 upgrade problems remain for specific setups (#1913).
|
||||
|
||||
## Before you upgrade
|
||||
|
||||
- Back up your Pulse data directory and configuration, and keep the backup until you have checked everything works.
|
||||
- This release includes the changes from v6.4.2, which was never published. If you use SSO only, map at least one trusted identity-provider group to the built-in `admin` role before upgrading so you keep admin access.
|
||||
- Windows Unified Agent binaries are not Authenticode-signed while SignPath remains unavailable, so Windows may show an Unknown Publisher warning. Verify downloads with the published checksums and detached signatures.
|
||||
- Pulse Mobile works with this release unchanged.
|
||||
- The rollback target is stable `v6.4.1`. On systemd and Proxmox LXC installs, use `sudo /bin/update --version v6.4.1`. For Docker Compose, pin `rcourtman/pulse:6.4.1` and recreate the container.
|
||||
|
||||
|
|
@ -28,6 +28,13 @@ _CUSTOMER_SECTION_HEADINGS = {
|
|||
"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?"
|
||||
|
|
@ -251,16 +258,23 @@ def _validate_customer_facing_release_notes(text: str, version: str) -> None:
|
|||
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 _CUSTOMER_SECTION_HEADINGS:
|
||||
if normalized not in allowed_headings:
|
||||
raise ReleaseBodyIntegrityError(
|
||||
"customer-facing release notes may only use What's improved, "
|
||||
"Fixes, Before you upgrade, and Known issues sections"
|
||||
"supported customer change groups, Highlights, Before you upgrade, "
|
||||
"and Known issues sections"
|
||||
)
|
||||
if normalized in headings:
|
||||
raise ReleaseBodyIntegrityError(
|
||||
|
|
@ -272,28 +286,35 @@ def _validate_customer_facing_release_notes(text: str, version: str) -> None:
|
|||
(key for key in ("what's improved", "what’s improved") if key in headings),
|
||||
None,
|
||||
)
|
||||
if improvements_key is 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"
|
||||
"customer-facing release notes must contain a What's improved section "
|
||||
"or a supported customer change group"
|
||||
)
|
||||
|
||||
improvements = _flat_bullet_items(
|
||||
_section_lines(text, headings[improvements_key]),
|
||||
"What's improved",
|
||||
)
|
||||
if not improvements:
|
||||
raise ReleaseBodyIntegrityError(
|
||||
"What's improved must contain at least one bullet"
|
||||
)
|
||||
for item in improvements:
|
||||
if len(item) > _MAX_CUSTOMER_ITEM_LENGTH:
|
||||
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"customer-facing bullets must be {_MAX_CUSTOMER_ITEM_LENGTH} characters or fewer"
|
||||
)
|
||||
if 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"
|
||||
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):
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import textwrap
|
||||
import tempfile
|
||||
|
|
@ -31,6 +33,123 @@ def _discover_rc_draft_packet_paths() -> tuple[str, ...]:
|
|||
|
||||
|
||||
class RenderReleaseBodyTest(unittest.TestCase):
|
||||
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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue