mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-09 18:15:50 +00:00
Enforce customer-facing release notes
This commit is contained in:
@@ -729,15 +729,15 @@ jobs:
|
||||
--unsigned-windows-exception "${{ needs.prepare.outputs.unsigned_windows_exception }}" \
|
||||
--unsigned-windows-reason "${{ needs.prepare.outputs.unsigned_windows_reason }}"
|
||||
|
||||
# Highlights provide the compact pre-update preview. The post-update
|
||||
# dialog uses categorized changelog entries instead of that summary.
|
||||
if grep -qiE '^#{1,6}[[:space:]]+highlights\b' "$RENDERED_NOTES_FILE"; then
|
||||
echo "::notice::Release notes include Highlights — the update banner can preview them before users update."
|
||||
# Customer-facing improvements provide the compact pre-update preview.
|
||||
# Historical Highlights sections remain supported for older packets.
|
||||
if grep -qiE "^#{1,6}[[:space:]]+(highlights|what.?s improved)\\b" "$RENDERED_NOTES_FILE"; then
|
||||
echo "::notice::Release notes include customer-facing improvements — the update banner can preview them before users update."
|
||||
else
|
||||
echo "::notice::Release notes have no Highlights — the update banner will not show a summary preview."
|
||||
echo "::notice::Release notes have no customer-facing improvements — the update banner will not show a summary preview."
|
||||
fi
|
||||
|
||||
if grep -qiE '^#{1,6}[[:space:]]+(added|new features|improved|improvements|changed|fixed|bug fixes|security|breaking changes|deprecated|removed)[[:space:]]*$' "$RENDERED_NOTES_FILE"; then
|
||||
if grep -qiE "^#{1,6}[[:space:]]+(what.?s improved|added|new features|improved|improvements|changed|fixed|fixes|bug fixes|security|breaking changes|deprecated|removed)[[:space:]]*$" "$RENDERED_NOTES_FILE"; then
|
||||
echo "::notice::Release notes include categorized changes — the post-update changelog dialog will show them."
|
||||
else
|
||||
echo "::notice::Release notes have no categorized changes — the post-update changelog dialog stays silent."
|
||||
|
||||
@@ -1069,6 +1069,18 @@ upgrade, update, release, or artifact-selection behavior.
|
||||
must render the public release body through a canonical sanitizer instead of
|
||||
publishing `Draft Release Notes` framing, `_DRAFT.md` packet links, or
|
||||
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
|
||||
upgrade` or `Known issues` for information users must act on or understand.
|
||||
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.
|
||||
The renderer appends only concise `Install` and exact `Roll back` sections;
|
||||
`docs/releases/RELEASE_NOTES_TEMPLATE.md` and
|
||||
`scripts/generate-release-notes.sh` are the canonical manual and generated
|
||||
authoring entry points for this shape.
|
||||
`docs/UPGRADE_v6.md` and its shipped frontend copy
|
||||
`frontend-modern/public/docs/UPGRADE_v6.md` must also stay byte-aligned with
|
||||
the current RC support packet so upgrade guidance does not keep pointing
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# Pulse vX.Y.Z Release Notes
|
||||
|
||||
One short paragraph explaining the customer outcome of the release. Lead with
|
||||
what feels better or works now, not how it was implemented.
|
||||
|
||||
## What's improved
|
||||
|
||||
- **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.
|
||||
|
||||
Use four 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.
|
||||
|
||||
## Fixes
|
||||
|
||||
- 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.
|
||||
|
||||
## Before you upgrade
|
||||
|
||||
Include only compatibility, migration, signing, companion-app, known-risk, or
|
||||
required operator information. Omit this section when users do not need to do
|
||||
or understand anything before upgrading.
|
||||
|
||||
The release pipeline appends the `Install` and `Roll back` sections from
|
||||
governed version metadata. Do not add release qualification, validation,
|
||||
workflow, gate, governance, or promotion-metadata sections to the customer
|
||||
notes.
|
||||
@@ -118,6 +118,12 @@ git push origin pulse/v6-release
|
||||
./scripts/trigger-release.sh "$RC_VERSION" "$RELEASE_NOTES_FILE"
|
||||
```
|
||||
|
||||
- Start new notes from `docs/releases/RELEASE_NOTES_TEMPLATE.md` or use the
|
||||
canonical generator. Lead with one short customer-outcome paragraph,
|
||||
keep `What's improved` to at most six scannable items, list concrete
|
||||
fixes, and include only user-relevant risk under `Before you upgrade`.
|
||||
Do not put qualification, validation, workflow, gate, governance, or
|
||||
promotion metadata into the public changelog.
|
||||
- Keep the current release-notes, changelog, and operator-support packet in
|
||||
sync. Do not update only one of them and treat the packet as ready.
|
||||
- Do not paste multiline release notes into the GitHub workflow form as the
|
||||
@@ -141,7 +147,9 @@ git push origin pulse/v6-release
|
||||
- Confirm assets exist and checksums match.
|
||||
- Confirm GitHub marks the release as a prerelease.
|
||||
- Confirm the rendered release body retains its standalone headings, lists,
|
||||
code fences, Installation section, and Promotion Metadata section.
|
||||
code fences, concise `Install` section, and exact `Roll back` section.
|
||||
Machine promotion metadata belongs in the governed workflow record, not
|
||||
in the customer-facing GitHub release body.
|
||||
- Smoke install on a test host/container.
|
||||
|
||||
5. Retry an unpublished draft only through the same helper:
|
||||
|
||||
@@ -1,62 +1,35 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "6e5571997e8b94b0a698c4c5c7486548c5ef7d42",
|
||||
"verified_at": "2026-08-25T14:27:03Z",
|
||||
"base_sha": "f18f15bf7b1fbdf1078bf412754c059694c8b996",
|
||||
"verified_at": "2026-08-25T15:58:23Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/hooks/useUnifiedResources.ts",
|
||||
"frontend-modern/src/stores/websocket-global.ts",
|
||||
"frontend-modern/src/stores/websocket.ts",
|
||||
"frontend-modern/src/utils/resourceStateAdapters.ts"
|
||||
"frontend-modern/src/components/UpdateBanner.tsx",
|
||||
"frontend-modern/src/components/whatsNewModel.ts"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/hooks/useUnifiedResources.ts": "7cad439395da8c5ccb281abe4b38313d63d45f87d38add8574120be5d1ab76a8",
|
||||
"frontend-modern/src/stores/websocket-global.ts": "644e5bccf8612259188d8cf3fd8b89e7b30fcc326bc717b69745f0b0c5ae5d6c",
|
||||
"frontend-modern/src/stores/websocket.ts": "e59b92a87db8f373129e7321e0bc06222ad59c57d25703aaf8f8ffc1d07b8fad",
|
||||
"frontend-modern/src/utils/resourceStateAdapters.ts": "099acb8c55827396ef0722b6b5afdd8d742e3811434ca498de4604ad05edb8fa"
|
||||
"frontend-modern/src/components/UpdateBanner.tsx": "455a8546b5b98c4541993ea7f894ad352c87a29392ede0da2a562e05f85b09de",
|
||||
"frontend-modern/src/components/whatsNewModel.ts": "d81c8ae4fcc59cff06a63b4a7407a97848acd934f6b6e04535059dc89f652471"
|
||||
},
|
||||
"routes": [
|
||||
"/proxmox/overview",
|
||||
"/proxmox/storage",
|
||||
"/proxmox/backups",
|
||||
"/proxmox/replication",
|
||||
"/proxmox/ceph",
|
||||
"/docker",
|
||||
"/kubernetes",
|
||||
"/truenas",
|
||||
"/vmware",
|
||||
"/standalone",
|
||||
"/alerts",
|
||||
"/ai",
|
||||
"/actions",
|
||||
"/settings"
|
||||
],
|
||||
"routes": ["/release-notes-verification.html"],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"width": 1280,
|
||||
"height": 720
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"live realtime metric ticks flowing through the patched-keys fast merge on the 50-node/1500-resource mock estate (pulse-dev rig build of this exact tree on :7696)",
|
||||
"per-cell DOM-vs-store consistency for workloads-table CPU cells across cohort updates (11/12 rows within 1% at two samples 30s apart; 12th is a group header without a CPU cell)",
|
||||
"warm tab re-entry catch-up through the changed-key history (Alerts, Proxmox, Storage/Overview sub-tab cycles)",
|
||||
"30s idle observation on Proxmox Overview at desktop and 390px/4x-throttle mobile",
|
||||
"nodes / workloads / storage tables rendered and live under realtime deltas; zero console errors across all probes on both viewport classes"
|
||||
"expanded update banner with a v6.4.0-rc.2 customer-facing What's improved section",
|
||||
"desktop banner rendered both improvement bullets and excluded the Fixes section from the preview",
|
||||
"narrow banner retained the full preview at 390px with document scroll width equal to client width"
|
||||
],
|
||||
"interactions": [
|
||||
"login",
|
||||
"desktop platform-tab rail navigation across all platforms and utility tabs",
|
||||
"mobile bottom-bar navigation including platform-switcher and More sheets",
|
||||
"Proxmox sub-tab cycle (Storage, Backups, Replication, Ceph, Overview) with repeated warm switches",
|
||||
"repeated warm Alerts entry (5-rep A/B harness)"
|
||||
"opened the isolated current-tree frontend fixture against the local mock backend",
|
||||
"expanded the update banner with Show more",
|
||||
"inspected rendered text, layout geometry, and horizontal overflow at desktop and narrow widths"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ const { mock } = vi.hoisted(() => ({
|
||||
composeUpCommand: string;
|
||||
}
|
||||
| undefined,
|
||||
releaseNotes: '',
|
||||
plan: {
|
||||
canAutoUpdate: true,
|
||||
requiresRoot: false,
|
||||
@@ -37,7 +38,7 @@ vi.mock('@/stores/updates', () => ({
|
||||
available: true,
|
||||
currentVersion: '6.0.0',
|
||||
latestVersion: '6.1.0',
|
||||
releaseNotes: '',
|
||||
releaseNotes: mock.releaseNotes,
|
||||
releaseDate: '',
|
||||
downloadUrl:
|
||||
'https://github.com/rcourtman/Pulse/releases/download/v6.1.0/pulse-v6.1.0-linux-amd64.tar.gz',
|
||||
@@ -71,6 +72,7 @@ afterEach(() => {
|
||||
mock.runtimeBuild = 'community';
|
||||
mock.deploymentType = 'systemd';
|
||||
mock.dockerUpdate = undefined;
|
||||
mock.releaseNotes = '';
|
||||
mock.plan = {
|
||||
canAutoUpdate: true,
|
||||
requiresRoot: false,
|
||||
@@ -200,3 +202,30 @@ describe('UpdateBanner Proxmox LXC update safety', () => {
|
||||
expect(screen.getByText(/Safe upgrade:/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UpdateBanner release-note preview', () => {
|
||||
it("shows customer-facing What's improved bullets in the expanded banner", async () => {
|
||||
mock.releaseNotes = `# Pulse v6.4.0-rc.2 Release Notes
|
||||
|
||||
Infrastructure views stay fast and predictable as your environment grows.
|
||||
|
||||
## What's improved
|
||||
|
||||
- **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.
|
||||
`;
|
||||
|
||||
render(() => <UpdateBanner />);
|
||||
await screen.findByRole('button', { name: 'Apply Update' });
|
||||
fireEvent.click(screen.getByTitle('Show more'));
|
||||
|
||||
expect(screen.getByText("What's new in v6.1.0")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Faster infrastructure views/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Lighter realtime updates/)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Saved API keys/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,9 +59,9 @@ export function UpdateBanner() {
|
||||
buildReleaseNotesUrl(updateStore.updateInfo()?.latestVersion),
|
||||
);
|
||||
|
||||
// Curated `## Highlights` section of the upcoming release, if the release
|
||||
// author wrote one — helps answer "is this update worth taking now?"
|
||||
// without leaving for GitHub. Empty when absent, and the block hides.
|
||||
// Curated customer-facing improvements from the upcoming release help
|
||||
// answer "is this update worth taking now?" without leaving for GitHub.
|
||||
// Historical `Highlights` notes remain supported. Empty notes stay hidden.
|
||||
const highlightsHtml = createMemo(() => {
|
||||
const info = updateStore.updateInfo();
|
||||
if (!info?.available || !info.releaseNotes) return '';
|
||||
@@ -284,7 +284,7 @@ export function UpdateBanner() {
|
||||
{updateStore.updateInfo()?.latestVersion}
|
||||
</p>
|
||||
|
||||
{/* What's new preview (curated Highlights section of the release notes) */}
|
||||
{/* What's new preview (customer-facing improvements or historical Highlights) */}
|
||||
<Show when={highlightsHtml()}>
|
||||
<div class="mt-2 p-3 rounded-md border bg-blue-100 dark:bg-blue-950 border-blue-300 dark:border-blue-700 text-blue-800 dark:text-blue-200">
|
||||
<div class="font-medium mb-1">
|
||||
|
||||
@@ -73,6 +73,42 @@ describe('extractHighlights', () => {
|
||||
expect(extractHighlights('## Changelog\n- fix things')).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the customer-facing What's improved section when Highlights is absent", () => {
|
||||
const body = [
|
||||
'# Pulse v6.4.0 Release Notes',
|
||||
'',
|
||||
'Pulse is faster in larger environments.',
|
||||
'',
|
||||
"## What's improved",
|
||||
'',
|
||||
'- **Faster large environments** — 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.',
|
||||
].join('\n');
|
||||
|
||||
expect(extractHighlights(body)).toBe(
|
||||
[
|
||||
'- **Faster large environments** — Tables stay responsive as estates grow.',
|
||||
'- **Lighter realtime updates** — Pages do less work when resources change.',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it('prefers historical Highlights when both preview headings exist', () => {
|
||||
const body = [
|
||||
'## Highlights',
|
||||
'- Short preview.',
|
||||
'',
|
||||
"## What's improved",
|
||||
'- **Longer detail** — Full customer-facing explanation.',
|
||||
].join('\n');
|
||||
|
||||
expect(extractHighlights(body)).toBe('- Short preview.');
|
||||
});
|
||||
|
||||
it('returns null when the Highlights section is empty', () => {
|
||||
expect(extractHighlights('## Highlights\n\n## Changelog\n- fix')).toBeNull();
|
||||
});
|
||||
@@ -148,6 +184,27 @@ describe('extractChangelog', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("includes What's improved in the post-update changelog", () => {
|
||||
const body = [
|
||||
"## What's improved",
|
||||
'- **Faster large environments** — Tables stay responsive as estates grow.',
|
||||
'## Fixes',
|
||||
'- Storage rows remain visible after refresh.',
|
||||
].join('\n');
|
||||
|
||||
expect(extractChangelog(body)).toBe(
|
||||
[
|
||||
'### Improved',
|
||||
'',
|
||||
'- **Faster large environments** — Tables stay responsive as estates grow.',
|
||||
'',
|
||||
'### Fixed',
|
||||
'',
|
||||
'- Storage rows remain visible after refresh.',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves nested details inside a recognized category', () => {
|
||||
const body = [
|
||||
'## Fixed',
|
||||
|
||||
@@ -26,14 +26,18 @@ const sectionBody = (lines: string[], headings: Heading[], headingIndex: number)
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract the contents of the `## Highlights` section from a GitHub release
|
||||
* body. Returns null when the section is missing or empty, which callers
|
||||
* treat as "nothing worth announcing".
|
||||
* Extract the compact pre-update summary from a GitHub release body. Current
|
||||
* customer-facing notes use `What's improved`; historical releases may still
|
||||
* carry `Highlights`.
|
||||
*/
|
||||
export const extractHighlights = (markdown: string): string | null => {
|
||||
const lines = markdown.replace(/\r\n/g, '\n').split('\n');
|
||||
const headings = headingsIn(lines);
|
||||
const headingIndex = headings.findIndex((heading) => /^highlights\b/i.test(heading.title));
|
||||
const highlightsIndex = headings.findIndex((heading) => /^highlights\b/i.test(heading.title));
|
||||
const improvementsIndex = headings.findIndex((heading) =>
|
||||
/^what(?:'|’)s improved\b/i.test(heading.title),
|
||||
);
|
||||
const headingIndex = highlightsIndex === -1 ? improvementsIndex : highlightsIndex;
|
||||
if (headingIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
@@ -47,8 +51,11 @@ const CHANGELOG_SECTION_LABELS: Readonly<Record<string, string>> = {
|
||||
'new features': 'Added',
|
||||
improved: 'Improved',
|
||||
improvements: 'Improved',
|
||||
"what's improved": 'Improved',
|
||||
'what’s improved': 'Improved',
|
||||
changed: 'Changed',
|
||||
fixed: 'Fixed',
|
||||
fixes: 'Fixed',
|
||||
'bug fixes': 'Fixed',
|
||||
security: 'Security',
|
||||
'breaking changes': 'Breaking changes',
|
||||
|
||||
@@ -65,56 +65,55 @@ notice. Ignore internal refactors, test changes, CI/tooling, and docs.
|
||||
|
||||
Write the release notes in exactly this format:
|
||||
|
||||
## v${VERSION}
|
||||
# Pulse v${VERSION} Release Notes
|
||||
|
||||
### Highlights
|
||||
[2-3 short bullets covering only what a typical user would notice and care
|
||||
about. This is the entire overview shown inside Pulse after an update. Use one
|
||||
plain-text sentence per bullet, no more than 140 characters. Say what improved
|
||||
and why it matters in everyday words; avoid component names, acronyms,
|
||||
implementation details, links, issue numbers, and Markdown formatting.
|
||||
IMPORTANT: if this release has nothing a typical user would notice (only
|
||||
internal fixes or minor patches), OMIT this entire section — that deliberately
|
||||
keeps the in-app banner silent for maintenance releases. Keep the heading at
|
||||
level 3 (###).]
|
||||
[One short paragraph explaining the customer outcome of this release. Lead
|
||||
with what feels better or works now, not how it was implemented.]
|
||||
|
||||
### Added
|
||||
[Genuinely new user-facing capabilities. Name the page, workflow, integration,
|
||||
or platform where users will find each one, then say what they can now do.]
|
||||
## What's improved
|
||||
|
||||
### Improved
|
||||
[Meaningful changes to existing behavior. Name the affected experience and the
|
||||
observable improvement; do not summarize several unrelated changes together.]
|
||||
- **[Short outcome]** — [Where users notice it and why it matters.]
|
||||
|
||||
### Fixed
|
||||
[Problems users would have encountered. State the visible symptom that no
|
||||
longer happens, not the internal cause. Include issue refs like (#1234) only
|
||||
when the fix verifiably addresses that issue.]
|
||||
[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
|
||||
risk information. Omit this section when there is nothing users need to do or
|
||||
understand before upgrading.]
|
||||
|
||||
Guidelines:
|
||||
- Plain, factual, understated. No marketing language, no emojis.
|
||||
- Omit any section that has no items.
|
||||
- Every Added, Improved, and Fixed bullet must stand on its own as a concrete
|
||||
changelog entry. A reader should understand where they would notice the
|
||||
change and what is different without knowing Pulse's implementation.
|
||||
- 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.
|
||||
- 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.
|
||||
- Do not use vague entries such as "improved agent handling" or "various UI
|
||||
fixes". Split unrelated changes and name the behavior that changed.
|
||||
- Do NOT write an Installation section or anything after Fixed — the
|
||||
release pipeline appends those.
|
||||
- Highlights is the ONE exception to "boring": it is shown in-app before users
|
||||
update, so make it the shortest useful preview of what changed — still
|
||||
factual, with no hype.
|
||||
- Do NOT write Install, Roll back, Promotion Metadata, Release Qualification,
|
||||
validation, gate, workflow, or governance sections. The release pipeline
|
||||
appends concise install and rollback instructions, while machine promotion
|
||||
records stay outside the customer changelog.
|
||||
|
||||
Your reply must be ONLY the release-notes markdown, starting with
|
||||
"## v${VERSION}" — no preamble, no code fences, no commentary.
|
||||
"# Pulse v${VERSION} Release Notes" — no preamble, no code fences, no
|
||||
commentary.
|
||||
EOF
|
||||
|
||||
# Strip accidental markdown fences and anything before the "## v" heading.
|
||||
# Strip accidental markdown fences and anything before the release title.
|
||||
clean_notes() {
|
||||
sed -e 's/^```[a-z]*$//' -e 's/^```$//' | awk '/^## v/{found=1} found{print}'
|
||||
sed -e 's/^```[a-z]*$//' -e 's/^```$//' | \
|
||||
awk -v title="# Pulse v${VERSION} Release Notes" '$0 == title {found=1} found{print}'
|
||||
}
|
||||
|
||||
# Both engines must run on the logged-in subscription (Claude Max / OpenAI
|
||||
@@ -174,7 +173,7 @@ esac
|
||||
RELEASE_NOTES=$(printf '%s\n' "$RELEASE_NOTES" | clean_notes)
|
||||
|
||||
if [ -z "$RELEASE_NOTES" ]; then
|
||||
echo "Error: release notes generation returned no '## v' section" >&2
|
||||
echo "Error: release notes generation returned no canonical release title" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -1250,6 +1250,8 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
|
||||
recorder = read("scripts/release_control/record_rc_to_ga_rehearsal.py")
|
||||
internal_recorder = read("scripts/release_control/internal/record_rc_to_ga_rehearsal.py")
|
||||
renderer = read("scripts/release_control/render_release_body.py")
|
||||
generator = read("scripts/generate-release-notes.sh")
|
||||
release_notes_template = read("docs/releases/RELEASE_NOTES_TEMPLATE.md")
|
||||
resolver = read("scripts/release_control/resolve_release_promotion.py")
|
||||
self.assertIn("GitHub Actions run URL", template)
|
||||
self.assertIn("Exact GA date to publish with GA", template)
|
||||
@@ -1297,7 +1299,11 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
|
||||
self.assertIn("2-core hosted runners", workflow)
|
||||
self.assertIn("resolve_release_promotion.py", release_workflow)
|
||||
self.assertIn("render_release_body.py", release_workflow)
|
||||
self.assertIn("build_promotion_metadata_section", renderer)
|
||||
self.assertIn("build_rollback_section", renderer)
|
||||
self.assertIn("# Pulse v${VERSION} Release Notes", generator)
|
||||
self.assertIn("## What's improved", generator)
|
||||
self.assertIn("## What's improved", release_notes_template)
|
||||
self.assertIn("promotion-metadata", release_notes_template)
|
||||
self.assertIn("default_output_path", internal_recorder)
|
||||
self.assertIn("output path already exists", internal_recorder)
|
||||
self.assertIn("default_output_path", recorder)
|
||||
@@ -1375,7 +1381,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
|
||||
self.assertIn('REQUIRED_BRANCH: ${{ steps.branch_policy.outputs.required_branch }}', content)
|
||||
self.assertIn("resolve_release_promotion.py", content)
|
||||
self.assertIn("render_release_body.py", content)
|
||||
self.assertIn("build_promotion_metadata_section", renderer)
|
||||
self.assertIn("build_rollback_section", renderer)
|
||||
self.assertIn("uses: ./.github/workflows/publish-docker.yml", content)
|
||||
self.assertIn("release-convergence.yml/dispatches", content)
|
||||
self.assertIn("Release Activation Commit Verdict", content)
|
||||
@@ -1394,9 +1400,8 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
|
||||
self.assertIn("recorded rollback target plus exact", source_of_truth)
|
||||
self.assertIn("hours of prerelease soak", resolver)
|
||||
self.assertIn("minimum is 72 hours unless hotfix_exception is true", resolver)
|
||||
self.assertIn("build_promotion_metadata_section", renderer)
|
||||
self.assertIn("Planned GA date", renderer)
|
||||
self.assertIn("Planned v5 end-of-support date", renderer)
|
||||
self.assertIn("build_rollback_section", renderer)
|
||||
self.assertIn("promotion metadata out of customer notes", renderer)
|
||||
self.assertIn("historical_asset_backfill_only:", content)
|
||||
self.assertIn("Repair an already-published release packet in place without rebuilding binaries", content)
|
||||
self.assertIn("draft: true", content)
|
||||
@@ -1623,8 +1628,8 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
|
||||
self.assertIn("./scripts/install.sh --version", helper)
|
||||
self.assertIn("v6 GA date to publish with GA", helper)
|
||||
self.assertIn("--arg ga_date \"$GA_DATE\"", helper)
|
||||
self.assertIn("Planned GA date", renderer)
|
||||
self.assertIn("Planned v5 end-of-support date", renderer)
|
||||
self.assertIn("ga_date", resolver)
|
||||
self.assertIn("v5_eos_date", resolver)
|
||||
self.assertIn("Stable v6.0.0 requires v5_eos_date in YYYY-MM-DD form", resolver)
|
||||
self.assertIn("release_notes must include the Pulse v5 maintenance-only support notice", resolver)
|
||||
dry_run_workflow = read(".github/workflows/release-dry-run.yml")
|
||||
|
||||
@@ -21,6 +21,30 @@ _VALIDATION_STATUS_BLOCK_RE = re.compile(
|
||||
|
||||
_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",
|
||||
}
|
||||
_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"
|
||||
@@ -32,6 +56,10 @@ _ISSUE_REFERENCE_RE = re.compile(
|
||||
)
|
||||
_MAX_HIGHLIGHT_ITEMS = 3
|
||||
_MAX_HIGHLIGHT_LENGTH = 140
|
||||
_MAX_CUSTOMER_IMPROVEMENT_ITEMS = 6
|
||||
_MAX_CUSTOMER_FIX_ITEMS = 12
|
||||
_MAX_CUSTOMER_ITEM_LENGTH = 260
|
||||
_MAX_CUSTOMER_SUMMARY_LENGTH = 600
|
||||
|
||||
|
||||
def _normalize_newlines(text: str) -> str:
|
||||
@@ -123,6 +151,136 @@ def _highlight_items(text: str) -> list[str] | None:
|
||||
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 _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) -> None:
|
||||
"""Enforce concise public notes without release-control implementation prose."""
|
||||
|
||||
lines = _normalize_newlines(text).strip().splitlines()
|
||||
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"
|
||||
)
|
||||
|
||||
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:
|
||||
raise ReleaseBodyIntegrityError(
|
||||
"customer-facing release notes may only use What's improved, "
|
||||
"Fixes, 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,
|
||||
)
|
||||
if improvements_key is None:
|
||||
raise ReleaseBodyIntegrityError(
|
||||
"customer-facing release notes must contain a What's improved section"
|
||||
)
|
||||
|
||||
improvements = _flat_bullet_items(
|
||||
_section_lines(text, headings[improvements_key]),
|
||||
"What's improved",
|
||||
)
|
||||
if not improvements or len(improvements) > _MAX_CUSTOMER_IMPROVEMENT_ITEMS:
|
||||
raise ReleaseBodyIntegrityError(
|
||||
"What's improved must contain between 1 and "
|
||||
f"{_MAX_CUSTOMER_IMPROVEMENT_ITEMS} bullets"
|
||||
)
|
||||
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 re.match(r"^\*\*[^*]+\*\*[ \t]+(?:—|-)[ \t]+\S", item):
|
||||
raise ReleaseBodyIntegrityError(
|
||||
"What's improved bullets must start with a short bold outcome followed by a dash"
|
||||
)
|
||||
|
||||
if "fixes" in headings:
|
||||
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."""
|
||||
|
||||
@@ -155,6 +313,8 @@ 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)
|
||||
|
||||
|
||||
def strip_validation_status_block(text: str) -> str:
|
||||
@@ -173,34 +333,43 @@ def validate_release_body_shape(
|
||||
"""Validate a stored GitHub release body and return its authored body."""
|
||||
|
||||
clean_body = strip_validation_status_block(body)
|
||||
validate_release_notes_shape(clean_body, version)
|
||||
|
||||
if clean_body.count("## Installation\n") != 1:
|
||||
if clean_body.count("## Install\n") != 1:
|
||||
raise ReleaseBodyIntegrityError(
|
||||
"published release body must contain exactly one Installation section"
|
||||
"published release body must contain exactly one Install section"
|
||||
)
|
||||
if clean_body.count("## Promotion Metadata\n") != 1:
|
||||
if clean_body.count("## Roll back\n") != 1:
|
||||
raise ReleaseBodyIntegrityError(
|
||||
"published release body must contain exactly one Promotion Metadata section"
|
||||
"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("## Installation\n")
|
||||
promotion_index = clean_body.index("## Promotion Metadata\n")
|
||||
if installation_index >= promotion_index:
|
||||
installation_index = clean_body.index("## Install\n")
|
||||
rollback_index = clean_body.index("## Roll back\n")
|
||||
if installation_index >= rollback_index:
|
||||
raise ReleaseBodyIntegrityError(
|
||||
"Installation must precede Promotion Metadata in the published body"
|
||||
"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 Installation"
|
||||
"published release body has no authored section before Install"
|
||||
)
|
||||
validate_release_notes_shape(authored_prefix, version)
|
||||
|
||||
if expected_body is not None:
|
||||
expected_clean = strip_validation_status_block(expected_body)
|
||||
@@ -262,7 +431,10 @@ 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", "## Promotion Metadata"})
|
||||
text = _drop_level_two_sections(
|
||||
text,
|
||||
{"## Installation", "## Install", "## Roll back", "## Promotion Metadata"},
|
||||
)
|
||||
text = _drop_draft_packet_links(text)
|
||||
return _collapse_blank_lines(text)
|
||||
|
||||
@@ -270,47 +442,33 @@ def sanitize_release_notes(raw_text: str, version: str) -> str:
|
||||
def build_installation_section(version: str) -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
"## Installation",
|
||||
"## Install",
|
||||
"",
|
||||
"**Docker (recommended):**",
|
||||
"```bash",
|
||||
f"docker pull rcourtman/pulse:{version}",
|
||||
"```",
|
||||
"",
|
||||
"**Docker Compose:**",
|
||||
f"Update your `docker-compose.yml` to use `rcourtman/pulse:{version}`",
|
||||
f"For Docker Compose, update the image to `rcourtman/pulse:{version}` and recreate the container.",
|
||||
"",
|
||||
"See the [Installation Guide](https://github.com/rcourtman/Pulse#installation) for complete setup instructions.",
|
||||
"",
|
||||
f"Review the [Code signing policy](https://github.com/rcourtman/Pulse/blob/v{version}/docs/CODE_SIGNING_POLICY.md) for release provenance, approval roles, and signing scope.",
|
||||
"",
|
||||
"Paid Pulse Pro, Relay, and eligible legacy customers: public GitHub release assets and the public `rcourtman/pulse` Docker image are community builds. They do not include the private Pulse Pro runtime hooks. Use https://pulserelay.pro/download.html with your activation key to get the private Pulse Pro Docker image or Linux/LXC archive.",
|
||||
"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_promotion_metadata_section(args: argparse.Namespace) -> str:
|
||||
lines = [
|
||||
"## Promotion Metadata",
|
||||
"",
|
||||
f"- Promotion channel: {args.promotion_channel}",
|
||||
f"- Candidate stable tag: {args.candidate_tag}",
|
||||
f"- Promoted prerelease tag: {args.promoted_prerelease_tag or 'n/a'}",
|
||||
f"- Rollback target: {args.rollback_target}",
|
||||
f"- Rollback command: `{args.rollback_command}`",
|
||||
]
|
||||
if args.planned_ga_date:
|
||||
lines.append(f"- Planned GA date: {args.planned_ga_date}")
|
||||
if args.planned_v5_eos_date:
|
||||
lines.append(f"- Planned v5 end-of-support date: {args.planned_v5_eos_date}")
|
||||
lines.append(f"- Hotfix exception: {args.hotfix_exception}")
|
||||
if args.hotfix_reason:
|
||||
lines.append(f"- Hotfix reason: {args.hotfix_reason}")
|
||||
lines.append(f"- Windows Authenticode required: {args.require_windows_signing}")
|
||||
lines.append(f"- Unsigned Windows exception: {args.unsigned_windows_exception}")
|
||||
if args.unsigned_windows_reason:
|
||||
lines.append(f"- Unsigned Windows reason: {args.unsigned_windows_reason}")
|
||||
return "\n".join(lines)
|
||||
def build_rollback_section(args: argparse.Namespace) -> str:
|
||||
return "\n".join(
|
||||
[
|
||||
"## Roll back",
|
||||
"",
|
||||
f"The rollback target is `{args.rollback_target}`:",
|
||||
"",
|
||||
"```bash",
|
||||
args.rollback_command,
|
||||
"```",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
@@ -397,7 +555,7 @@ def main() -> int:
|
||||
sections = [
|
||||
sanitized,
|
||||
build_installation_section(args.version),
|
||||
build_promotion_metadata_section(args),
|
||||
build_rollback_section(args),
|
||||
]
|
||||
rendered = "\n\n".join(sections) + "\n"
|
||||
validate_release_body_shape(rendered, args.version)
|
||||
|
||||
@@ -189,6 +189,106 @@ 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
|
||||
|
||||
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.
|
||||
|
||||
## Fixes
|
||||
|
||||
- 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")
|
||||
|
||||
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_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_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("## Fixes", template)
|
||||
self.assertIn("## Before you upgrade", template)
|
||||
self.assertIn("four to six meaningful improvements", 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"
|
||||
@@ -233,7 +333,7 @@ Old metadata section.
|
||||
self.assertNotIn("## Installation", sanitized)
|
||||
self.assertNotIn("## Promotion Metadata", sanitized)
|
||||
|
||||
def test_main_renders_single_installation_and_promotion_metadata_sections(self) -> None:
|
||||
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"
|
||||
@@ -276,26 +376,18 @@ Old metadata section.
|
||||
sections = [
|
||||
sanitized,
|
||||
render_release_body.build_installation_section(namespace.version),
|
||||
render_release_body.build_promotion_metadata_section(namespace),
|
||||
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("## Installation"), 1)
|
||||
self.assertEqual(body.count("## Promotion Metadata"), 1)
|
||||
self.assertEqual(body.count("## Install"), 1)
|
||||
self.assertEqual(body.count("## Roll back"), 1)
|
||||
self.assertNotIn("## Promotion Metadata", body)
|
||||
self.assertIn("docker pull rcourtman/pulse:6.0.0-rc.2", body)
|
||||
self.assertIn(
|
||||
"[Code signing policy](https://github.com/rcourtman/Pulse/blob/v6.0.0-rc.2/docs/CODE_SIGNING_POLICY.md)",
|
||||
body,
|
||||
)
|
||||
self.assertIn(
|
||||
"public GitHub release assets and the public `rcourtman/pulse` Docker image are community builds",
|
||||
body,
|
||||
)
|
||||
self.assertIn("https://pulserelay.pro/download.html", body)
|
||||
self.assertIn("- Rollback target: v5.1.28", body)
|
||||
self.assertIn("- Windows Authenticode required: false", body)
|
||||
self.assertIn("- Unsigned Windows exception: false", body)
|
||||
self.assertIn("The rollback target is `v5.1.28`", body)
|
||||
self.assertIn("./scripts/install.sh --version v5.1.28", body)
|
||||
render_release_body.validate_release_body_shape(body, "6.0.0-rc.2")
|
||||
|
||||
def test_flattened_release_notes_fail_closed(self) -> None:
|
||||
@@ -326,13 +418,13 @@ Intro.
|
||||
|
||||
- Patrol findings stay governed.
|
||||
|
||||
## Installation
|
||||
## Install
|
||||
|
||||
Install details.
|
||||
|
||||
## Promotion Metadata
|
||||
## Roll back
|
||||
|
||||
- Promotion channel: rc
|
||||
Rollback details.
|
||||
"""
|
||||
validation_block = """<!-- VALIDATION_STATUS_START -->
|
||||
## Release Asset Validation: PASSED
|
||||
@@ -365,13 +457,13 @@ Assets passed.
|
||||
|
||||
Intro.## Highlights- Patrol findings stay governed.
|
||||
|
||||
## Installation
|
||||
## Install
|
||||
|
||||
Install details.
|
||||
|
||||
## Promotion Metadata
|
||||
## Roll back
|
||||
|
||||
- Promotion channel: rc
|
||||
Rollback details.
|
||||
"""
|
||||
with self.assertRaisesRegex(
|
||||
render_release_body.ReleaseBodyIntegrityError,
|
||||
|
||||
Reference in New Issue
Block a user