Fix published release body rendering

This commit is contained in:
rcourtman 2026-04-16 22:20:17 +01:00
parent 6fcdcf8b8e
commit b19991f4ae
8 changed files with 290 additions and 49 deletions

View file

@ -577,49 +577,27 @@ jobs:
if [ -n "$RELEASE_NOTES_INPUT" ]; then
printf "%s\n" "$RELEASE_NOTES_INPUT" > "$NOTES_FILE"
else
echo "Release $VERSION" > "$NOTES_FILE"
echo "# Pulse v${VERSION} Release Notes" > "$NOTES_FILE"
echo "" >> "$NOTES_FILE"
echo "See commit history for changes." >> "$NOTES_FILE"
fi
{
echo ""
echo "## Installation"
echo ""
echo "**Docker (recommended):**"
echo '```bash'
echo "docker pull rcourtman/pulse:${VERSION}"
echo '```'
echo ""
echo "**Docker Compose:**"
echo "Update your \`docker-compose.yml\` to use \`rcourtman/pulse:${VERSION}\`"
echo ""
echo "See the [Installation Guide](https://github.com/rcourtman/Pulse#installation) for complete setup instructions."
echo ""
echo "## Promotion Metadata"
echo ""
echo "- Promotion channel: ${{ needs.prepare.outputs.is_prerelease == 'true' && 'rc' || 'stable' }}"
echo "- Candidate stable tag: ${{ needs.prepare.outputs.tag }}"
if [ -n "${{ needs.prepare.outputs.promoted_from_tag }}" ]; then
echo "- Promoted prerelease tag: ${{ needs.prepare.outputs.promoted_from_tag }}"
else
echo "- Promoted prerelease tag: n/a"
fi
echo "- Rollback target: ${{ needs.prepare.outputs.rollback_tag }}"
echo "- Rollback command: \`${{ needs.prepare.outputs.rollback_command }}\`"
if [ -n "${{ needs.prepare.outputs.ga_date }}" ]; then
echo "- Planned GA date: ${{ needs.prepare.outputs.ga_date }}"
fi
if [ -n "${{ needs.prepare.outputs.v5_eos_date }}" ]; then
echo "- Planned v5 end-of-support date: ${{ needs.prepare.outputs.v5_eos_date }}"
fi
echo "- Hotfix exception: ${{ needs.prepare.outputs.hotfix_exception }}"
if [ -n "${{ needs.prepare.outputs.hotfix_reason }}" ]; then
echo "- Hotfix reason: ${{ needs.prepare.outputs.hotfix_reason }}"
fi
} >> "$NOTES_FILE"
RENDERED_NOTES_FILE=$(mktemp)
python3 scripts/release_control/render_release_body.py \
--version "$VERSION" \
--release-notes-file "$NOTES_FILE" \
--output "$RENDERED_NOTES_FILE" \
--promotion-channel "${{ needs.prepare.outputs.is_prerelease == 'true' && 'rc' || 'stable' }}" \
--candidate-tag "${{ needs.prepare.outputs.tag }}" \
--promoted-prerelease-tag "${{ needs.prepare.outputs.promoted_from_tag }}" \
--rollback-target "${{ needs.prepare.outputs.rollback_tag }}" \
--rollback-command "${{ needs.prepare.outputs.rollback_command }}" \
--planned-ga-date "${{ needs.prepare.outputs.ga_date }}" \
--planned-v5-eos-date "${{ needs.prepare.outputs.v5_eos_date }}" \
--hotfix-exception "${{ needs.prepare.outputs.hotfix_exception }}" \
--hotfix-reason "${{ needs.prepare.outputs.hotfix_reason }}"
echo "notes_file=${NOTES_FILE}" >> $GITHUB_OUTPUT
echo "notes_file=${RENDERED_NOTES_FILE}" >> $GITHUB_OUTPUT
- name: Locate existing release
id: existing_release

2
.gitignore vendored
View file

@ -247,6 +247,8 @@ scripts/release_control/*
!scripts/release_control/release_promotion_policy_support.py
!scripts/release_control/release_promotion_policy_support_test.py
!scripts/release_control/release_promotion_policy_test.py
!scripts/release_control/render_release_body.py
!scripts/release_control/render_release_body_test.py
!scripts/release_control/resolve_release_promotion.py
!scripts/release_control/resolve_release_promotion_test.py
!scripts/release_control/repo_file_io.py

View file

@ -95,11 +95,11 @@ server-side update execution surfaces.
## Extension Points
1. Add or change deployment-type detection, update planning, or apply behavior through `internal/updates/`
2. Add or change release-build metadata injection, Docker build-context allowlists, release artifact assembly, governed promotion metadata resolution, the canonical version file, operator-facing release packet content, prerelease feedback intake wording, or the canonical in-repo v6 upgrade guide through `scripts/build-release.sh`, `scripts/release_ldflags.sh`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/resolve_release_promotion.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `.dockerignore`, `Dockerfile`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/UPGRADE_v6.md`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, the operator dispatch helpers `scripts/trigger-release.sh` and `scripts/trigger-release-dry-run.sh`, and the governed release workflows `.github/workflows/create-release.yml`, `.github/workflows/deploy-demo-server.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, and `.github/workflows/update-demo-server.yml`
2. Add or change release-build metadata injection, Docker build-context allowlists, release artifact assembly, governed promotion metadata resolution, the canonical version file, operator-facing release packet content, prerelease feedback intake wording, or the canonical in-repo v6 upgrade guide through `scripts/build-release.sh`, `scripts/release_ldflags.sh`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/resolve_release_promotion.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `.dockerignore`, `Dockerfile`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/UPGRADE_v6.md`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, the operator dispatch helpers `scripts/trigger-release.sh` and `scripts/trigger-release-dry-run.sh`, and the governed release workflows `.github/workflows/create-release.yml`, `.github/workflows/deploy-demo-server.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, and `.github/workflows/update-demo-server.yml`
3. Add or change shell installer, Windows installer, container-agent installer, or auto-update script behavior through `scripts/install.sh`, `scripts/install.ps1`, `scripts/install-container-agent.sh`, and `scripts/pulse-auto-update.sh`
4. Add or change server update transport through `internal/api/updates.go` and `frontend-modern/src/api/updates.ts`
5. Add or change local dev-runtime orchestration, managed ownership, browser-runtime proof wiring, frontend/backend coherence diagnostics, canonical developer entry wrappers, dependency manifest floors, frontend build chunking, or dev-runtime helper control surfaces through `scripts/hot-dev.sh`, `scripts/hot-dev-bg.sh`, `Makefile`, `package.json`, `package-lock.json`, `frontend-modern/package.json`, `frontend-modern/package-lock.json`, `frontend-modern/vite.config.ts`, `go.mod`, `go.sum`, `scripts/dev-check.sh`, `scripts/toggle-mock.sh`, `scripts/clean-mock-alerts.sh`, `scripts/dev-launchd-setup.sh`, `scripts/dev-launchd-wrapper.sh`, `scripts/run_demo_public_browser_smoke.sh`, `scripts/demo_public_browser_smoke.cjs`, `scripts/com.pulse.hot-dev.plist.template`, `tests/integration/scripts/managed-dev-runtime.mjs`, `tests/integration/playwright.config.ts`, `tests/integration/tests/helpers.ts`, `tests/integration/tests/runtime-defaults.ts`, `tests/integration/README.md`, and `tests/integration/QUICK_START.md`
6. Add or change governed release-promotion workflow inputs, operator-facing promotion metadata, the canonical version file, prerelease feedback intake prompts, artifact publication lineage enforcement, release note or changelog packet composition, or stable-promotion rehearsal summaries through `.github/workflows/create-release.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/update-demo-server.yml`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `scripts/trigger-release.sh`, and `scripts/trigger-release-dry-run.sh`
6. Add or change governed release-promotion workflow inputs, operator-facing promotion metadata, the canonical version file, prerelease feedback intake prompts, artifact publication lineage enforcement, release note or changelog packet composition, or stable-promotion rehearsal summaries through `.github/workflows/create-release.yml`, `.github/workflows/helm-pages.yml`, `.github/workflows/publish-docker.yml`, `.github/workflows/publish-helm-chart.yml`, `.github/workflows/promote-floating-tags.yml`, `.github/workflows/release-dry-run.yml`, `.github/workflows/update-demo-server.yml`, `.github/ISSUE_TEMPLATE/v6_rc_feedback.yml`, `docs/RELEASE_NOTES.md`, `docs/releases/`, `docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md`, `docs/release-control/v6/internal/PRE_RELEASE_CHECKLIST.md`, `docs/release-control/v6/internal/RC_TO_GA_REHEARSAL_TEMPLATE.md`, `scripts/check-workflow-dispatch-inputs.py`, `scripts/release_control/render_release_body.py`, `scripts/release_control/record_rc_to_ga_rehearsal.py`, `scripts/release_control/internal/record_rc_to_ga_rehearsal.py`, `scripts/release_control/release_promotion_policy_support.py`, `scripts/trigger-release.sh`, and `scripts/trigger-release-dry-run.sh`
That release-promotion boundary also owns prerelease note packet lineage:
shipped RC notes must remain historically accurate, the top-level
`docs/RELEASE_NOTES.md` index must continue to point at the current shipped
@ -111,6 +111,11 @@ server-side update execution surfaces.
than only one markdown file, and prerelease runbook commands should stay
parameterized to the current candidate version instead of hard-coding stale
`rc.1` examples once later RCs exist.
Published release bodies must also stay publication-safe even when operators
feed draft packet content into the workflow: `.github/workflows/create-release.yml`
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.
`docs/UPGRADE_v6.md` must also stay aligned with the current RC support
packet so upgrade guidance does not keep pointing operators at retired
rollout/support docs after a later RC packet is prepared.
@ -250,6 +255,11 @@ unpublished tag must locate the existing draft release, retarget its git tag
and release `target_commitish` to the current governed release-line head, and
continue publication without requiring an operator to delete the tag manually;
published tags remain immutable and must still fail closed.
That same public release-body boundary also owns publish-safe packet rendering.
When operators pass draft packet markdown to `.github/workflows/create-release.yml`,
the workflow must sanitize draft-only framing and append the standardized
installation and promotion metadata sections exactly once, rather than trusting
raw packet text to already be publish-safe.
That same frontend-release boundary also owns shared header-composition proof.
`.github/workflows/release-dry-run.yml` and `.github/workflows/create-release.yml`
must both run the same `lint:headers` audit so a branch that would be rejected

View file

@ -2452,6 +2452,7 @@
"scripts/release_control/internal/record_rc_to_ga_rehearsal.py",
"scripts/release_control/record_rc_to_ga_rehearsal.py",
"scripts/release_control/release_promotion_policy_support.py",
"scripts/release_control/render_release_body.py",
"scripts/release_control/resolve_release_promotion.py",
"scripts/release_ldflags.sh",
"scripts/run_demo_public_browser_smoke.sh",
@ -2542,6 +2543,7 @@
"scripts/release_control/internal/record_rc_to_ga_rehearsal.py",
"scripts/release_control/record_rc_to_ga_rehearsal.py",
"scripts/release_control/release_promotion_policy_support.py",
"scripts/release_control/render_release_body.py",
"scripts/release_control/resolve_release_promotion.py",
"scripts/trigger-release-dry-run.sh",
"scripts/trigger-release.sh",
@ -2553,6 +2555,7 @@
"scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py",
"scripts/release_control/release_promotion_policy_support_test.py",
"scripts/release_control/release_promotion_policy_test.py",
"scripts/release_control/render_release_body_test.py",
"scripts/release_control/resolve_release_promotion_test.py"
]
},

View file

@ -173,6 +173,7 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
dry_run_trigger = read("scripts/trigger-release-dry-run.sh")
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")
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)
@ -193,7 +194,8 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
self.assertIn("Planned GA date", workflow)
self.assertIn("Planned v5 end-of-support date", workflow)
self.assertIn("resolve_release_promotion.py", release_workflow)
self.assertIn("- Rollback command:", release_workflow)
self.assertIn("render_release_body.py", release_workflow)
self.assertIn("build_promotion_metadata_section", renderer)
self.assertIn("default_output_path", internal_recorder)
self.assertIn("output path already exists", internal_recorder)
self.assertIn("default_output_path", recorder)
@ -214,17 +216,23 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
def test_release_workflow_enforces_rc_lineage_soak_and_v5_notice(self) -> None:
content = read(".github/workflows/create-release.yml")
helper = read("scripts/trigger-release.sh")
renderer = read("scripts/release_control/render_release_body.py")
policy = read("docs/release-control/v6/internal/RELEASE_PROMOTION_POLICY.md")
source_of_truth = read("docs/release-control/v6/internal/SOURCE_OF_TRUTH.md")
runbook = read("docs/releases/V6_PRERELEASE_RUNBOOK.md")
resolver = read("scripts/release_control/resolve_release_promotion.py")
contract = read("docs/release-control/v6/internal/subsystems/deployment-installability.md")
self.assertIn("control_plane.py --branch-for-version", content)
self.assertIn('git fetch --prune origin main "${REQUIRED_BRANCH}" --tags', content)
self.assertIn('REQUIRED_BRANCH: ${{ steps.branch_policy.outputs.required_branch }}', content)
self.assertIn("resolve_release_promotion.py", content)
self.assertIn("Rollback command:", content)
self.assertIn("render_release_body.py", content)
self.assertIn("build_promotion_metadata_section", renderer)
self.assertIn('gh workflow run publish-docker.yml --ref "${REQUIRED_BRANCH}"', content)
self.assertIn('gh workflow run update-demo-server.yml --ref "${REQUIRED_BRANCH}"', content)
self.assertIn("sanitize_release_notes", renderer)
self.assertIn("Do not treat this as published", renderer)
self.assertIn("_DRAFT.md", renderer)
self.assertIn("rollback target and exact reinstall command recorded", policy)
self.assertIn("rc-to-ga-rehearsal-summary", policy)
self.assertIn("record_rc_to_ga_rehearsal.py --run-id <run-id>", policy)
@ -232,11 +240,9 @@ 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("- Candidate stable tag:", content)
self.assertIn("- Promotion channel:", content)
self.assertIn("- Promoted prerelease tag:", content)
self.assertIn("ga_date", content)
self.assertIn("v5_eos_date", content)
self.assertIn("build_promotion_metadata_section", renderer)
self.assertIn("Planned GA date", renderer)
self.assertIn("Planned v5 end-of-support date", renderer)
self.assertIn("draft: ${{ github.event.inputs.draft_only == 'true' }}", content)
self.assertIn('gh api "repos/${{ github.repository }}/releases?per_page=100" --paginate', content)
self.assertIn('git push origin "refs/tags/${TAG}" --force', content)
@ -246,8 +252,8 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
self.assertIn("./scripts/install.sh --version", helper)
self.assertIn("v6 GA date to publish with GA", helper)
self.assertIn("-f ga_date", helper)
self.assertIn("Planned GA date", content)
self.assertIn("Planned v5 end-of-support date", content)
self.assertIn("Planned GA date", renderer)
self.assertIn("Planned v5 end-of-support date", renderer)
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")
@ -285,6 +291,11 @@ class ReleasePromotionPolicyTest(unittest.TestCase):
self.assertIn("printf '%s\\n' \"$RC_VERSION\" > VERSION", runbook)
self.assertIn("markdown text from the current release-notes packet", runbook)
self.assertIn("Keep the current release-notes, changelog, and operator-support packet in", runbook)
self.assertIn("Published release bodies must also stay publication-safe", contract)
self.assertIn(
"append the standardized installation and promotion metadata sections exactly once",
normalize_ws(contract),
)
def test_release_artifact_workflows_refuse_stable_without_matching_rc(self) -> None:
publish = read(".github/workflows/publish-docker.yml")

View file

@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Render a publish-safe GitHub release body from the current RC packet."""
from __future__ import annotations
import argparse
import re
from pathlib import Path
def _normalize_newlines(text: str) -> str:
return text.replace("\r\n", "\n").replace("\r", "\n")
def _replace_draft_heading(text: str, version: str) -> str:
lines = text.splitlines()
for index, line in enumerate(lines):
if line.startswith("# ") and "Draft Release Notes" in line:
lines[index] = f"# Pulse v{version} Release Notes"
return "\n".join(lines)
return text
def _drop_draft_disclaimer(text: str) -> str:
pattern = re.compile(
r"(?:^|\n)_?Draft only\. Do not treat this as published.*?(?:\n{2,}|$)",
re.DOTALL,
)
return pattern.sub("\n\n", text, count=1)
def _drop_level_two_sections(text: str, headings: set[str]) -> str:
lines = text.splitlines()
kept: list[str] = []
skip = False
for line in lines:
stripped = line.strip()
if stripped in headings:
skip = True
continue
if skip and stripped.startswith("## "):
skip = False
if not skip:
kept.append(line)
return "\n".join(kept)
def _drop_draft_packet_links(text: str) -> str:
return "\n".join(line for line in text.splitlines() if "_DRAFT.md" not in line)
def _collapse_blank_lines(text: str) -> str:
text = _normalize_newlines(text).strip()
text = re.sub(r"\n{3,}", "\n\n", text)
return text + "\n"
def sanitize_release_notes(raw_text: str, version: str) -> str:
text = _normalize_newlines(raw_text)
text = _replace_draft_heading(text, version)
text = _drop_draft_disclaimer(text)
text = _drop_level_two_sections(text, {"## Installation", "## Promotion Metadata"})
text = _drop_draft_packet_links(text)
return _collapse_blank_lines(text)
def build_installation_section(version: str) -> str:
return "\n".join(
[
"## Installation",
"",
"**Docker (recommended):**",
"```bash",
f"docker pull rcourtman/pulse:{version}",
"```",
"",
"**Docker Compose:**",
f"Update your `docker-compose.yml` to use `rcourtman/pulse:{version}`",
"",
"See the [Installation Guide](https://github.com/rcourtman/Pulse#installation) for complete setup instructions.",
]
)
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}")
return "\n".join(lines)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--version", required=True)
parser.add_argument("--release-notes-file", required=True)
parser.add_argument("--output", required=True)
parser.add_argument("--promotion-channel", required=True)
parser.add_argument("--candidate-tag", required=True)
parser.add_argument("--promoted-prerelease-tag", default="")
parser.add_argument("--rollback-target", required=True)
parser.add_argument("--rollback-command", required=True)
parser.add_argument("--planned-ga-date", default="")
parser.add_argument("--planned-v5-eos-date", default="")
parser.add_argument("--hotfix-exception", required=True)
parser.add_argument("--hotfix-reason", default="")
return parser.parse_args()
def main() -> int:
args = parse_args()
raw_text = Path(args.release_notes_file).read_text(encoding="utf-8")
sanitized = sanitize_release_notes(raw_text, args.version).rstrip("\n")
sections = [
sanitized,
build_installation_section(args.version),
build_promotion_metadata_section(args),
]
Path(args.output).write_text("\n\n".join(sections) + "\n", encoding="utf-8")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Regression tests for publish-safe release body rendering."""
from __future__ import annotations
import tempfile
import unittest
from pathlib import Path
import render_release_body
class RenderReleaseBodyTest(unittest.TestCase):
def test_sanitize_release_notes_strips_draft_markers_duplicate_sections_and_draft_links(self) -> None:
raw = """# Pulse v6.0.0-rc.2 Draft Release Notes
_Draft only. Do not treat this as published until the governed `v6.0.0-rc.2`
tag and GitHub prerelease exist._
Intro paragraph.
## Operator References
- `docs/releases/V6_RC2_OPERATOR_SUPPORT_PACK_DRAFT.md`
- `docs/UPGRADE_v6.md`
## Installation
Old install section.
## Promotion Metadata
Old metadata section.
"""
sanitized = render_release_body.sanitize_release_notes(raw, "6.0.0-rc.2")
self.assertIn("# Pulse v6.0.0-rc.2 Release Notes", sanitized)
self.assertNotIn("Draft Release Notes", sanitized)
self.assertNotIn("Draft only. Do not treat this as published", sanitized)
self.assertNotIn("_DRAFT.md", sanitized)
self.assertIn("- `docs/UPGRADE_v6.md`", sanitized)
self.assertNotIn("## Installation", sanitized)
self.assertNotIn("## Promotion Metadata", sanitized)
def test_main_renders_single_installation_and_promotion_metadata_sections(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
notes_file = Path(tmp) / "notes.md"
output_file = Path(tmp) / "body.md"
notes_file.write_text(
"# Pulse v6.0.0-rc.2 Draft Release Notes\n\n"
"_Draft only. Do not treat this as published until the governed `v6.0.0-rc.2` tag and GitHub prerelease exist._\n\n"
"Body.\n",
encoding="utf-8",
)
args = render_release_body.parse_args.__wrapped__ if hasattr(render_release_body.parse_args, "__wrapped__") else None
del args # satisfy linters if wrapped implementation changes later
namespace = type(
"Args",
(),
{
"version": "6.0.0-rc.2",
"release_notes_file": str(notes_file),
"output": str(output_file),
"promotion_channel": "rc",
"candidate_tag": "v6.0.0-rc.2",
"promoted_prerelease_tag": "",
"rollback_target": "v5.1.28",
"rollback_command": "./scripts/install.sh --version v5.1.28",
"planned_ga_date": "",
"planned_v5_eos_date": "",
"hotfix_exception": "false",
"hotfix_reason": "",
},
)()
raw_text = Path(namespace.release_notes_file).read_text(encoding="utf-8")
sanitized = render_release_body.sanitize_release_notes(raw_text, namespace.version).rstrip("\n")
sections = [
sanitized,
render_release_body.build_installation_section(namespace.version),
render_release_body.build_promotion_metadata_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.assertIn("docker pull rcourtman/pulse:6.0.0-rc.2", body)
self.assertIn("- Rollback target: v5.1.28", body)
if __name__ == "__main__":
unittest.main()

View file

@ -4416,6 +4416,7 @@ class SubsystemLookupTest(unittest.TestCase):
"scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py",
"scripts/release_control/release_promotion_policy_support_test.py",
"scripts/release_control/release_promotion_policy_test.py",
"scripts/release_control/render_release_body_test.py",
"scripts/release_control/resolve_release_promotion_test.py",
],
)
@ -4449,6 +4450,7 @@ class SubsystemLookupTest(unittest.TestCase):
"scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py",
"scripts/release_control/release_promotion_policy_support_test.py",
"scripts/release_control/release_promotion_policy_test.py",
"scripts/release_control/render_release_body_test.py",
"scripts/release_control/resolve_release_promotion_test.py",
],
)
@ -4482,6 +4484,7 @@ class SubsystemLookupTest(unittest.TestCase):
"scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py",
"scripts/release_control/release_promotion_policy_support_test.py",
"scripts/release_control/release_promotion_policy_test.py",
"scripts/release_control/render_release_body_test.py",
"scripts/release_control/resolve_release_promotion_test.py",
],
)
@ -4515,6 +4518,7 @@ class SubsystemLookupTest(unittest.TestCase):
"scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py",
"scripts/release_control/release_promotion_policy_support_test.py",
"scripts/release_control/release_promotion_policy_test.py",
"scripts/release_control/render_release_body_test.py",
"scripts/release_control/resolve_release_promotion_test.py",
],
)
@ -4548,6 +4552,7 @@ class SubsystemLookupTest(unittest.TestCase):
"scripts/release_control/internal/record_rc_to_ga_rehearsal_test.py",
"scripts/release_control/release_promotion_policy_support_test.py",
"scripts/release_control/release_promotion_policy_test.py",
"scripts/release_control/render_release_body_test.py",
"scripts/release_control/resolve_release_promotion_test.py",
],
)