fix(release): make changelog ranges channel-aware

This commit is contained in:
rcourtman
2026-08-27 11:56:06 +01:00
parent 248b242034
commit ff27c3881f
9 changed files with 276 additions and 66 deletions
@@ -29,6 +29,21 @@ The next-candidate release notes and changelog must also describe newly stable
integration fields when external receivers need them to consume the release;
packet proof keeps the customer-facing summary and detailed changelog aligned.
Release-note comparison ranges are channel-specific. Each RC compares against
the immediately preceding RC on the same version line, with RC1 comparing
against the previous stable release. A stable GA release compares against the
previous stable release, not the final RC, so it can retell the complete release
train as a small number of user-recognizable themes. Stable notes must synthesize
the final product outcomes instead of concatenating RC notes or enumerating the
underlying commits.
Customer-facing notes use one outcome list for features and fixes. Each visible
change is described once under `What's improved`; a parallel `Fixes` section is
forbidden for packets from `v6.4.0-rc.6` onward because it encourages the same
change to be restated with slightly different implementation detail. Internal
toolchain and architecture work stays in the detailed changelog unless it
changes something users can recognize or act on.
Provider-hosted MSP deploy artifacts must package the provider control plane as
a least-privilege Docker provisioner. The packaged compose/setup path must avoid
whole-host and Docker-data read mounts, expose storage admission only through
+14 -8
View File
@@ -7,18 +7,24 @@ what feels better or works now, not how it was implemented.
- **Short outcome** — Explain where users notice it and why it matters.
- **Short outcome** — Keep each item concrete and independently useful.
- **Short outcome** — Prefer observable behavior over component names.
- **Short outcome** — Use plain language and avoid implementation detail.
- **Short outcome** — Include fixes here as outcomes rather than repeating
them in a second section.
Use four to six meaningful improvements for a normal RC or minor release. A
Use up to six meaningful improvements for a normal RC or minor release. 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 names, group related implementation work into one
user-recognizable theme, and use plain language.
## Fixes
For an RC, cover only changes since the immediately preceding RC (or the
previous stable release for RC1). Do not repeat improvements already announced
in an earlier RC. For a stable GA release, cover the complete release train
since the previous stable release and boil the full commit range down to a few
themes that explain what users will experience differently. Do not concatenate
the RC notes or attempt to list every commit.
- State a visible problem that no longer happens.
- Name the affected page, workflow, integration, or platform when useful.
Omit this section only when there are genuinely no user-facing fixes.
Do not add a separate `Fixes` section. That shape encourages the same change to
be described twice as both an improvement and a fix.
## Before you upgrade
+6 -15
View File
@@ -1,23 +1,14 @@
# Pulse v6.4.0-rc.6 Release Notes
`v6.4.0-rc.6` is a release candidate for the next v6 minor release. It restores complete hardware detail for standalone Proxmox Backup Server hosts, prevents large LXC estates from losing filesystem usage, warns when SMART CRC counters grow, and completes the alert engine's policy consolidation.
`v6.4.0-rc.6` focuses on more complete host data and earlier hardware-fault detection, while making alert behavior more predictable ahead of the stable release.
## What's improved
- **Complete standalone PBS details** — Backup-server rows now open the full resource drawer with system, hardware, network, disk, thermal, service, history, and management information when a Pulse Agent source is available.
- **Complete LXC filesystem coverage** — Host agents read LXC mount usage through `/proc`, avoiding `pct df` lock contention that could leave later containers without filesystem data on larger hosts.
- **Earlier disk-cabling warnings** — Pulse establishes a SMART UDMA CRC baseline and raises a disk-health warning when the counter grows, helping expose link or cabling faults that a current-state health check can miss.
- **More consistent alerts** — Alert policy is resolved consistently through one declarative path, with the superseded transition-tracking maps removed.
- **Current release toolchain** — Release builds use Go 1.26.7 and its current security fixes.
## Fixes
- Standalone PBS hosts no longer lose their agent-reported hardware details when they are represented on the Proxmox backup surface ([#1723](https://github.com/rcourtman/Pulse/issues/1723)).
- LXC filesystem collection now uses host-namespace `statfs` with a bounded per-container fallback, preventing shared-budget starvation on larger hosts ([#1477](https://github.com/rcourtman/Pulse/issues/1477)).
- SMART UDMA CRC growth now creates a warning, treats a counter reset or disk replacement as a new baseline, and resolves the active growth event after a stable sample ([#1776](https://github.com/rcourtman/Pulse/issues/1776)).
- Alert configuration values, built-in defaults, and enabled state now use one policy fold across resource families instead of parallel resolution paths.
- Alert-event queries now allocate from the bounded effective result limit rather than an untrusted requested limit.
- Legacy alert transition state has been removed after the reducer cutover, eliminating a second mutable source of lifecycle truth.
- **Complete standalone PBS details** — Standalone PBS entries retain agent-reported system, hardware, network, disk, thermal, service, history, and management details in the full drawer ([#1723](https://github.com/rcourtman/Pulse/issues/1723)).
- **Complete LXC filesystem coverage** — Filesystem usage continues to populate across larger LXC hosts instead of later containers being left without mount data when earlier collection is slow ([#1477](https://github.com/rcourtman/Pulse/issues/1477)).
- **Earlier disk-cabling warnings** — SMART UDMA CRC growth now raises a warning, resets its baseline after a counter reset or disk replacement, and clears the active event after a stable sample ([#1776](https://github.com/rcourtman/Pulse/issues/1776)).
- **More predictable alerts** — Enabled state, configured values, delays, and built-in defaults now follow the same policy path across resource types, with the superseded lifecycle state removed.
- **Safer alert-history queries** — Oversized requested result limits are bounded before memory is reserved, preventing the request from driving an unnecessarily large allocation.
## Before you upgrade
+3 -8
View File
@@ -5,23 +5,18 @@ _This changelog describes the changes since `v6.4.0-rc.5` included in
## Added
- SMART UDMA CRC counter growth now raises a disk-health warning after the initial baseline sample.
- Standalone PBS backup-server entries expose the full merged host resource drawer when agent hardware evidence is available.
- SMART UDMA CRC counter growth now raises a disk-health warning after the initial baseline sample, treats counter resets and disk replacements as a new baseline, and resolves an active growth event after a stable follow-up sample ([#1776](https://github.com/rcourtman/Pulse/issues/1776)).
## Changed
- Alert configuration resolves through one declarative policy fold for enabled state, thresholds, delays, and built-in defaults.
- Legacy transition-tracking maps have been removed now that reducer-owned alert lifecycle state is authoritative.
- Proxmox LXC filesystem collection uses host-namespace `statfs` for configured mounts, with `pct df` retained as a bounded per-container fallback when `/proc/<pid>/root` is unavailable.
- Alert configuration resolves through one declarative policy fold for enabled state, thresholds, delays, and built-in defaults. Legacy transition-tracking maps have been removed now that reducer-owned alert lifecycle state is authoritative.
- Proxmox LXC filesystem collection uses host-namespace `statfs` for configured mounts, with `pct df` retained as a bounded per-container fallback when `/proc/<pid>/root` is unavailable, so slow earlier containers no longer starve later containers of filesystem data ([#1477](https://github.com/rcourtman/Pulse/issues/1477)).
- Release builds use Go 1.26.7 across the public server, agent, provider control plane, and release scripts.
## Fixed
- Standalone PBS rows open the canonical resource drawer instead of a limited four-field summary, restoring system, hardware, network, disks, thermals, services, history, and management context ([#1723](https://github.com/rcourtman/Pulse/issues/1723)).
- LXC filesystem discovery no longer gives every container's `pct df` call one shared deadline, which previously starved later containers and left their mounted filesystems unreported on larger hosts ([#1477](https://github.com/rcourtman/Pulse/issues/1477)).
- SMART CRC growth treats resets and disk replacements as a new baseline and resolves an active growth event on a stable follow-up sample ([#1776](https://github.com/rcourtman/Pulse/issues/1776)).
- Alert-event queries allocate from the bounded effective result limit rather than the raw client-supplied limit.
- Alert policy evaluation no longer relies on duplicated resource-family lookup branches or legacy mutable transition mirrors.
## Release Metadata
+81 -18
View File
@@ -7,7 +7,8 @@
# The agent runs read-only git/gh commands itself instead of being fed
# pre-chewed diff fragments, so nothing user-visible is missed by grep luck.
#
# Usage: ./scripts/generate-release-notes.sh <version> [previous-tag]
# Usage: ./scripts/generate-release-notes.sh <version> [comparison-tag]
# ./scripts/generate-release-notes.sh --resolve-base <version>
#
# Contract: the release notes markdown is written to STDOUT (trigger-release.sh
# captures it); all progress/diagnostics go to STDERR. SAVE_TO_FILE=1 also
@@ -19,30 +20,91 @@
set -euo pipefail
MODE=generate
if [ "${1:-}" = "--resolve-base" ]; then
MODE=resolve-base
shift
fi
VERSION=${1:-}
PREVIOUS_TAG=${2:-}
REQUESTED_COMPARISON_TAG=${2:-}
if [ -z "$VERSION" ]; then
echo "Usage: $0 <version> [previous-tag]" >&2
echo "Example: $0 6.1.0 v6.0.6" >&2
echo "Usage: $0 <version> [comparison-tag]" >&2
echo " $0 --resolve-base <version>" >&2
echo "Example: $0 6.4.0-rc.6" >&2
exit 1
fi
cd "$(git rev-parse --show-toplevel)"
if [ -z "$PREVIOUS_TAG" ]; then
PREVIOUS_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
if [ -z "$PREVIOUS_TAG" ]; then
echo "No previous tag found, cannot generate diff-based release notes" >&2
exit 1
VERSION=${VERSION#v}
latest_stable_before() {
local target_tag="v$1"
local candidate
while IFS= read -r candidate; do
[ "$candidate" = "$target_tag" ] && continue
if [ "$(printf '%s\n%s\n' "$candidate" "$target_tag" | sort -V | head -n 1)" = "$candidate" ]; then
printf '%s\n' "$candidate"
fi
done < <(git tag --list 'v*' | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V)
}
resolve_comparison_tag() {
local version=$1
local base rc expected
if [[ "$version" =~ ^([0-9]+\.[0-9]+\.[0-9]+)-rc\.([0-9]+)$ ]]; then
base=${BASH_REMATCH[1]}
rc=${BASH_REMATCH[2]}
if (( rc > 1 )); then
expected="v${base}-rc.$((rc - 1))"
if ! git merge-base --is-ancestor "$expected" HEAD 2>/dev/null; then
echo "Expected immediately preceding RC tag '$expected' is not an ancestor of HEAD" >&2
return 1
fi
printf '%s\n' "$expected"
return
fi
elif [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Unsupported release version '$version'; expected X.Y.Z or X.Y.Z-rc.N" >&2
return 1
fi
latest_stable_before "${base:-$version}" | tail -n 1
}
EXPECTED_COMPARISON_TAG=$(resolve_comparison_tag "$VERSION")
if [ -z "$EXPECTED_COMPARISON_TAG" ]; then
echo "No valid comparison tag found for v${VERSION}" >&2
exit 1
fi
if ! git rev-parse -q --verify "${PREVIOUS_TAG}^{commit}" >/dev/null; then
echo "Previous tag '${PREVIOUS_TAG}' does not exist" >&2
if [ -n "$REQUESTED_COMPARISON_TAG" ] && [ "$REQUESTED_COMPARISON_TAG" != "$EXPECTED_COMPARISON_TAG" ]; then
echo "Comparison tag '$REQUESTED_COMPARISON_TAG' violates the release-note range for v${VERSION}; expected '$EXPECTED_COMPARISON_TAG'" >&2
exit 1
fi
PREVIOUS_TAG=$EXPECTED_COMPARISON_TAG
if ! git rev-parse -q --verify "${PREVIOUS_TAG}^{commit}" >/dev/null; then
echo "Comparison tag '${PREVIOUS_TAG}' does not exist" >&2
exit 1
fi
if [ "$MODE" = "resolve-base" ]; then
printf '%s\n' "$PREVIOUS_TAG"
exit 0
fi
if [[ "$VERSION" == *-rc.* ]]; then
RELEASE_RANGE_GUIDANCE="This is an RC release. Cover only the user-visible changes since the immediately preceding RC, ${PREVIOUS_TAG}. Do not repeat changes already announced in an earlier RC."
else
RELEASE_RANGE_GUIDANCE="This is a stable GA release. Cover the complete release train since the previous stable release, ${PREVIOUS_TAG}. Synthesize that potentially large commit range into a few user-relevant themes; do not concatenate RC notes or enumerate every commit."
fi
echo "Generating release notes for v${VERSION} (changes since ${PREVIOUS_TAG})..." >&2
read -r -d '' PROMPT <<EOF || true
@@ -60,8 +122,12 @@ commits that reference GitHub issues (#1234), you may use \`gh issue view\` to
understand the user-facing symptom. Only describe changes that exist in the
final code state; verify anything you are unsure about before writing it.
${RELEASE_RANGE_GUIDANCE}
Focus on USER-VISIBLE changes only: features, fixes, and behavior users will
notice. Ignore internal refactors, test changes, CI/tooling, and docs.
notice. Ignore internal refactors, test changes, CI/tooling, and docs. Group
related commits into the outcome a user would recognize, such as a rewritten
alert system, rather than listing the implementation steps separately.
Write the release notes in exactly this format:
@@ -77,12 +143,6 @@ with what feels better or works now, not how it was implemented.]
[Use 4-6 meaningful bullets for a normal RC or minor release. A narrow patch
may use fewer. Keep every bullet concrete and independently useful.]
## Fixes
- [A visible problem that no longer happens.]
[Omit this section only when there are genuinely no user-facing fixes.]
## Before you upgrade
[Only user-relevant compatibility, migration, signing, companion-app, or known
@@ -95,6 +155,9 @@ Guidelines:
- Every bullet must stand on its own. A reader should understand where they
would notice the change and what is different without knowing Pulse's
implementation.
- Put features and fixes together under What's improved. Describe each
user-visible outcome exactly once; do not repeat it under another heading or
split one outcome into separate feature and fix bullets.
- Avoid internal release and architecture vocabulary such as canonical,
governed, schema, provider transport, preflight, convergence, or runtime
boundary unless that exact term is visible to the user in the product.
@@ -11,6 +11,7 @@ import (
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"testing"
@@ -884,18 +885,26 @@ func TestCurrentPrereleasePacketTracksInstallMetadata(t *testing.T) {
assertFileContainsAllNormalized(t, releaseNotesPath,
"# Pulse v"+version+" Release Notes",
"## What's improved",
"## Fixes",
"## Before you upgrade",
"## Known issues",
"Complete standalone PBS details",
"Complete LXC filesystem coverage",
"Earlier disk-cabling warnings",
"Alert policy is resolved consistently",
"Alert-event queries now allocate",
"More predictable alerts",
"Safer alert-history queries",
"Pulse Mobile does not consume the changed PBS browser detail or alert evaluation internals",
"not Authenticode-signed",
"Unknown Publisher warning",
)
assertFileDoesNotContain(t, releaseNotesPath, "## Fixes")
for _, issueURL := range []string{
"https://github.com/rcourtman/Pulse/issues/1723",
"https://github.com/rcourtman/Pulse/issues/1477",
"https://github.com/rcourtman/Pulse/issues/1776",
} {
assertFileContainsExactlyOnce(t, releaseNotesPath, issueURL)
assertFileContainsExactlyOnce(t, changelogPath, issueURL)
}
assertFileContainsAllNormalized(t, changelogPath,
"Version: `v"+version+"`",
"Previous stable: `v"+previous+"`",
@@ -3065,6 +3074,74 @@ func assertFileContainsAll(t *testing.T, path string, required ...string) {
}
}
func TestReleaseNotesGeneratorResolvesChannelSpecificComparisonRanges(t *testing.T) {
repo := t.TempDir()
runGit := func(args ...string) string {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = repo
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, output)
}
return strings.TrimSpace(string(output))
}
commit := func(message string) {
t.Helper()
runGit("commit", "--allow-empty", "--no-gpg-sign", "-m", message)
}
runGit("init", "-b", "main")
runGit("config", "user.name", "Pulse Release Test")
runGit("config", "user.email", "release-test@example.invalid")
commit("stable 6.3.1")
runGit("tag", "v6.3.1")
runGit("checkout", "-b", "release-v6.3.2")
commit("stable 6.3.2 hotfix")
runGit("tag", "v6.3.2")
runGit("checkout", "main")
for rc := 1; rc <= 5; rc++ {
commit("release candidate " + strconv.Itoa(rc))
runGit("tag", "v6.4.0-rc."+strconv.Itoa(rc))
}
commit("release candidate 6 changes")
generator, err := filepath.Abs(repoFile("scripts", "generate-release-notes.sh"))
if err != nil {
t.Fatalf("resolve release-note generator path: %v", err)
}
resolve := func(version string) string {
t.Helper()
cmd := exec.Command("bash", generator, "--resolve-base", version)
cmd.Dir = repo
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("resolve comparison base for %s: %v\n%s", version, err, output)
}
return strings.TrimSpace(string(output))
}
if got := resolve("6.4.0-rc.6"); got != "v6.4.0-rc.5" {
t.Fatalf("RC comparison base = %q, want v6.4.0-rc.5", got)
}
if got := resolve("6.4.0-rc.1"); got != "v6.3.2" {
t.Fatalf("RC1 comparison base = %q, want v6.3.2", got)
}
if got := resolve("6.4.0"); got != "v6.3.2" {
t.Fatalf("GA comparison base = %q, want v6.3.2", got)
}
cmd := exec.Command("bash", generator, "6.4.0-rc.6", "v6.4.0-rc.4")
cmd.Dir = repo
output, err := cmd.CombinedOutput()
if err == nil {
t.Fatal("generator accepted a comparison tag older than the immediately preceding RC")
}
if !strings.Contains(string(output), "expected 'v6.4.0-rc.5'") {
t.Fatalf("unexpected comparison-range rejection:\n%s", output)
}
}
func assertFileContainsAllNormalized(t *testing.T, path string, required ...string) {
t.Helper()
content, err := os.ReadFile(path)
@@ -3079,6 +3156,34 @@ func assertFileContainsAllNormalized(t *testing.T, path string, required ...stri
}
}
func assertFileDoesNotContain(t *testing.T, path string, forbidden ...string) {
t.Helper()
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
s := string(content)
for _, needle := range forbidden {
if strings.Contains(s, needle) {
t.Fatalf("%s contains forbidden substring: %s", path, needle)
}
}
}
func assertFileContainsExactlyOnce(t *testing.T, path string, required ...string) {
t.Helper()
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
s := string(content)
for _, needle := range required {
if count := strings.Count(s, needle); count != 1 {
t.Fatalf("%s contains %q %d times, want exactly once", path, needle, count)
}
}
}
func normalizedInstallTestWhitespace(text string) string {
return strings.Join(strings.Fields(text), " ")
}
+21 -2
View File
@@ -170,6 +170,20 @@ def _requires_customer_facing_standard(version: str) -> bool:
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 _section_lines(text: str, heading_index: int) -> list[str]:
lines = _normalize_newlines(text).splitlines()
section: list[str] = []
@@ -198,7 +212,7 @@ def _flat_bullet_items(lines: list[str], section_name: str) -> list[str]:
return items
def _validate_customer_facing_release_notes(text: str) -> None:
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()
@@ -265,6 +279,11 @@ def _validate_customer_facing_release_notes(text: str) -> None:
)
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(
@@ -314,7 +333,7 @@ def validate_release_notes_shape(raw_text: str, version: str) -> None:
_highlight_items(text)
if _requires_customer_facing_standard(version):
_validate_customer_facing_release_notes(text)
_validate_customer_facing_release_notes(text, version)
def strip_validation_status_block(text: str) -> str:
@@ -190,7 +190,7 @@ class RenderReleaseBodyTest(unittest.TestCase):
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.2 Release Notes
notes = """# Pulse v6.4.0-rc.6 Release Notes
Pulse is faster and more predictable in larger environments.
@@ -198,17 +198,34 @@ Pulse is faster and more predictable in larger environments.
- **Faster infrastructure views** — Tables stay responsive as estates grow.
- **Lighter realtime updates** — Pages do less work when resources change.
## Fixes
- Saved API keys are no longer returned to the browser.
- **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.2")
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(
@@ -300,9 +317,11 @@ Pulse is faster and more predictable in larger environments.
).read_text(encoding="utf-8")
self.assertIn("## What's improved", template)
self.assertIn("## Fixes", template)
self.assertIn("## Before you upgrade", template)
self.assertIn("four to six meaningful improvements", 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("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)
+1 -4
View File
@@ -156,10 +156,7 @@ else
echo ""
if [[ ! $REPLY =~ ^[Nn]$ ]]; then
echo "Generating release notes..."
# Try to find previous tag for better context
PREV_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
if ./scripts/generate-release-notes.sh "$VERSION" "$PREV_TAG" > "$NOTES_FILE"; then
if ./scripts/generate-release-notes.sh "$VERSION" > "$NOTES_FILE"; then
echo "Release notes generated at ${NOTES_FILE}"
echo ""
# Show first few lines