mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 10:33:29 +00:00
Fix published release body rendering
This commit is contained in:
parent
6fcdcf8b8e
commit
b19991f4ae
8 changed files with 290 additions and 49 deletions
54
.github/workflows/create-release.yml
vendored
54
.github/workflows/create-release.yml
vendored
|
|
@ -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
2
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
138
scripts/release_control/render_release_body.py
Normal file
138
scripts/release_control/render_release_body.py
Normal 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())
|
||||
94
scripts/release_control/render_release_body_test.py
Normal file
94
scripts/release_control/render_release_body_test.py
Normal 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()
|
||||
|
|
@ -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",
|
||||
],
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue