From a502d5ab3ada7e44f801b45057648e19fd13b809 Mon Sep 17 00:00:00 2001
From: caezium <113233555+caezium@users.noreply.github.com>
Date: Wed, 5 Aug 2026 02:00:16 +0800
Subject: [PATCH] fix(release): keep internal notes out of Sparkle
---
.github/workflows/release.yml | 2 +
.../repair-sparkle-release-notes.yml | 243 ++++++++++++++++++
RELEASES.md | 2 -
docs/macos-signing.md | 13 +
scripts/tests/test_release_workflows.py | 30 +++
scripts/tests/test_validate_release_notes.py | 74 ++++++
scripts/tests/test_verify_sparkle_appcast.py | 35 +++
scripts/validate-release-notes.py | 64 +++++
scripts/verify-sparkle-appcast.py | 33 ++-
9 files changed, 493 insertions(+), 3 deletions(-)
create mode 100644 .github/workflows/repair-sparkle-release-notes.yml
create mode 100644 scripts/tests/test_validate_release_notes.py
create mode 100644 scripts/validate-release-notes.py
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index c044ea02..e7580103 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -438,6 +438,7 @@ jobs:
ZIP="$PWD/dist/Burrow-${VERSION}.zip"
APPCAST="$PWD/dist/appcast.xml"
ARCHIVE_URL="https://github.com/caezium/Burrow/releases/download/${GITHUB_REF_NAME}/Burrow-${VERSION}.zip"
+ python3 scripts/validate-release-notes.py RELEASES.md --version "$VERSION"
cp RELEASES.md "dist/Burrow-${VERSION}.md"
printf '%s' "$SPARKLE_PRIVATE_KEY" | "$SPARKLE_TOOLS/generate_appcast" \
--ed-key-file - \
@@ -452,6 +453,7 @@ jobs:
--version "$VERSION" \
--build "$BUILD_NUMBER" \
--url "$ARCHIVE_URL" \
+ --release-notes RELEASES.md \
--signature-output "$RUNNER_TEMP/sparkle-archive-signature.txt"
printf '%s' "$SPARKLE_PRIVATE_KEY" | \
"$SPARKLE_TOOLS/sign_update" --verify --ed-key-file - \
diff --git a/.github/workflows/repair-sparkle-release-notes.yml b/.github/workflows/repair-sparkle-release-notes.yml
new file mode 100644
index 00000000..7626e988
--- /dev/null
+++ b/.github/workflows/repair-sparkle-release-notes.yml
@@ -0,0 +1,243 @@
+name: repair Sparkle release notes
+
+on:
+ workflow_dispatch:
+ inputs:
+ tag:
+ description: Latest published tag whose signed appcast notes need repair
+ required: true
+ type: string
+
+permissions:
+ contents: write
+
+concurrency:
+ group: release
+ cancel-in-progress: false
+
+jobs:
+ repair:
+ runs-on: macos-15
+ timeout-minutes: 20
+ steps:
+ - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
+ with:
+ persist-credentials: false
+
+ - name: Validate the published release and notes source
+ id: target
+ env:
+ GH_TOKEN: ${{ github.token }}
+ REQUESTED_TAG: ${{ inputs.tag }}
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ run: |
+ set -euo pipefail
+ if [ "$GITHUB_REF" != "refs/heads/$DEFAULT_BRANCH" ]; then
+ echo "::error::Run this repair from the default branch ($DEFAULT_BRANCH)."
+ exit 1
+ fi
+
+ TAG="$REQUESTED_TAG"
+ if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+ echo "::error::Tag must be a stable semantic version such as v0.11.2."
+ exit 1
+ fi
+
+ LATEST="$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName --jq .tagName)"
+ if [ "$TAG" != "$LATEST" ]; then
+ echo "::error::Only the latest published release may be repaired (latest is $LATEST)."
+ exit 1
+ fi
+
+ read -r IS_DRAFT IS_PRERELEASE <<< "$(
+ gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \
+ --json isDraft,isPrerelease --jq '[.isDraft, .isPrerelease] | @tsv'
+ )"
+ if [ "$IS_DRAFT" != "false" ] || [ "$IS_PRERELEASE" != "false" ]; then
+ echo "::error::The repair target must be a published stable release."
+ exit 1
+ fi
+
+ VERSION="${TAG#v}"
+ python3 scripts/validate-release-notes.py RELEASES.md --version "$VERSION"
+
+ ASSET_NAME="Burrow-${VERSION}.zip"
+ DIGEST="$(
+ gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets \
+ --jq ".assets[] | select(.name == \"$ASSET_NAME\") | .digest"
+ )"
+ if [[ ! "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]; then
+ echo "::error::Published $ASSET_NAME is missing a usable SHA-256 digest."
+ exit 1
+ fi
+
+ {
+ echo "tag=$TAG"
+ echo "version=$VERSION"
+ echo "asset_name=$ASSET_NAME"
+ echo "asset_digest=$DIGEST"
+ } >> "$GITHUB_OUTPUT"
+
+ - name: Download and verify the unchanged notarized archive
+ id: archive
+ env:
+ GH_TOKEN: ${{ github.token }}
+ TAG: ${{ steps.target.outputs.tag }}
+ VERSION: ${{ steps.target.outputs.version }}
+ ASSET_NAME: ${{ steps.target.outputs.asset_name }}
+ EXPECTED_DIGEST: ${{ steps.target.outputs.asset_digest }}
+ run: |
+ set -euo pipefail
+ mkdir -p dist "$RUNNER_TEMP/unpacked"
+ gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \
+ --pattern "$ASSET_NAME" --dir dist
+ ZIP="$PWD/dist/$ASSET_NAME"
+ ACTUAL_DIGEST="sha256:$(shasum -a 256 "$ZIP" | awk '{print $1}')"
+ if [ "$ACTUAL_DIGEST" != "$EXPECTED_DIGEST" ]; then
+ echo "::error::Downloaded archive digest changed."
+ exit 1
+ fi
+
+ ditto -x -k "$ZIP" "$RUNNER_TEMP/unpacked"
+ APP="$RUNNER_TEMP/unpacked/Burrow.app"
+ INFO="$APP/Contents/Info.plist"
+ [ -d "$APP" ] || { echo "::error::Archive has no Burrow.app."; exit 1; }
+ ARTIFACT_VERSION="$(plutil -extract CFBundleShortVersionString raw "$INFO")"
+ BUILD_NUMBER="$(plutil -extract CFBundleVersion raw "$INFO")"
+ if [ "$ARTIFACT_VERSION" != "$VERSION" ] || [[ ! "$BUILD_NUMBER" =~ ^[0-9]+$ ]]; then
+ echo "::error::Archive version/build does not match the target release."
+ exit 1
+ fi
+ codesign --verify --deep --strict --verbose=2 "$APP"
+ xcrun stapler validate "$APP"
+ spctl --assess --type execute --verbose=2 "$APP"
+
+ {
+ echo "build_number=$BUILD_NUMBER"
+ echo "info_plist=$INFO"
+ } >> "$GITHUB_OUTPUT"
+
+ - name: Install checksum-pinned Sparkle release tools
+ run: |
+ TOOLS="$RUNNER_TEMP/sparkle-release-tools"
+ bash scripts/fetch-sparkle.sh --tools "$TOOLS"
+ echo "SPARKLE_TOOLS=$TOOLS/bin" >> "$GITHUB_ENV"
+
+ - name: Generate and verify the corrected signed feed
+ env:
+ SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
+ TAG: ${{ steps.target.outputs.tag }}
+ VERSION: ${{ steps.target.outputs.version }}
+ BUILD_NUMBER: ${{ steps.archive.outputs.build_number }}
+ ASSET_NAME: ${{ steps.target.outputs.asset_name }}
+ EXPECTED_DIGEST: ${{ steps.target.outputs.asset_digest }}
+ INFO_PLIST: ${{ steps.archive.outputs.info_plist }}
+ run: |
+ set -euo pipefail
+ [ -n "$SPARKLE_PRIVATE_KEY" ] \
+ || { echo "::error::SPARKLE_ED_PRIVATE_KEY is required."; exit 1; }
+
+ PUBLIC_KEY="$(plutil -extract SUPublicEDKey raw "$INFO_PLIST")"
+ DERIVED_PUBLIC="$(swift -e 'import Foundation; import CryptoKit; guard let encoded = ProcessInfo.processInfo.environment["SPARKLE_PRIVATE_KEY"], let seed = Data(base64Encoded: encoded, options: .ignoreUnknownCharacters), seed.count == 32 else { exit(1) }; let key = try Curve25519.Signing.PrivateKey(rawRepresentation: seed); print(key.publicKey.rawRepresentation.base64EncodedString())')"
+ if [ "$DERIVED_PUBLIC" != "$PUBLIC_KEY" ]; then
+ echo "::error::Sparkle private key does not match the public key in the released app."
+ exit 1
+ fi
+
+ ZIP="$PWD/dist/Burrow-${VERSION}.zip"
+ APPCAST="$PWD/dist/appcast.xml"
+ ARCHIVE_URL="https://github.com/caezium/Burrow/releases/download/${TAG}/Burrow-${VERSION}.zip"
+ cp RELEASES.md "dist/Burrow-${VERSION}.md"
+ printf '%s' "$SPARKLE_PRIVATE_KEY" | "$SPARKLE_TOOLS/generate_appcast" \
+ --ed-key-file - \
+ --download-url-prefix "https://github.com/caezium/Burrow/releases/download/${TAG}/" \
+ --embed-release-notes \
+ --maximum-deltas 0 \
+ --versions "$BUILD_NUMBER" \
+ --link "https://github.com/caezium/Burrow" \
+ -o "$APPCAST" "$PWD/dist"
+ python3 scripts/verify-sparkle-appcast.py "$APPCAST" \
+ --archive "$ZIP" \
+ --version "$VERSION" \
+ --build "$BUILD_NUMBER" \
+ --url "$ARCHIVE_URL" \
+ --release-notes RELEASES.md \
+ --signature-output "$RUNNER_TEMP/sparkle-archive-signature.txt"
+ printf '%s' "$SPARKLE_PRIVATE_KEY" | \
+ "$SPARKLE_TOOLS/sign_update" --verify --ed-key-file - \
+ "$ZIP" "$(< "$RUNNER_TEMP/sparkle-archive-signature.txt")"
+ printf '%s' "$SPARKLE_PRIVATE_KEY" | \
+ "$SPARKLE_TOOLS/sign_update" --verify --ed-key-file - "$APPCAST"
+
+ - name: Replace only the release body and signed appcast
+ env:
+ GH_TOKEN: ${{ github.token }}
+ TAG: ${{ steps.target.outputs.tag }}
+ run: |
+ set -euo pipefail
+ APPCAST="$PWD/dist/appcast.xml"
+ gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --notes-file RELEASES.md
+ gh release upload "$TAG" "$APPCAST" --repo "$GITHUB_REPOSITORY" --clobber
+
+ - name: Verify the published repair
+ env:
+ GH_TOKEN: ${{ github.token }}
+ SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
+ TAG: ${{ steps.target.outputs.tag }}
+ VERSION: ${{ steps.target.outputs.version }}
+ BUILD_NUMBER: ${{ steps.archive.outputs.build_number }}
+ run: |
+ set -euo pipefail
+ PUBLISHED="$RUNNER_TEMP/published"
+ mkdir -p "$PUBLISHED"
+ gh release download "$TAG" --repo "$GITHUB_REPOSITORY" \
+ --pattern appcast.xml --dir "$PUBLISHED"
+ cmp dist/appcast.xml "$PUBLISHED/appcast.xml"
+
+ ZIP="$PWD/dist/Burrow-${VERSION}.zip"
+ ARCHIVE_URL="https://github.com/caezium/Burrow/releases/download/${TAG}/Burrow-${VERSION}.zip"
+ python3 scripts/verify-sparkle-appcast.py "$PUBLISHED/appcast.xml" \
+ --archive "$ZIP" \
+ --version "$VERSION" \
+ --build "$BUILD_NUMBER" \
+ --url "$ARCHIVE_URL" \
+ --release-notes RELEASES.md
+ printf '%s' "$SPARKLE_PRIVATE_KEY" | \
+ "$SPARKLE_TOOLS/sign_update" --verify --ed-key-file - \
+ "$PUBLISHED/appcast.xml"
+
+ LATEST_URL="https://github.com/caezium/Burrow/releases/latest/download/appcast.xml"
+ for attempt in {1..12}; do
+ curl -fSL --retry 3 -o "$RUNNER_TEMP/latest-appcast.xml" "$LATEST_URL"
+ if cmp -s "$PUBLISHED/appcast.xml" "$RUNNER_TEMP/latest-appcast.xml"; then
+ break
+ fi
+ if [ "$attempt" -eq 12 ]; then
+ echo "::error::The latest-release URL did not serve the repaired appcast."
+ exit 1
+ fi
+ sleep 10
+ done
+
+ PUBLISHED_DIGEST="$(
+ gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets \
+ --jq ".assets[] | select(.name == \"$ASSET_NAME\") | .digest"
+ )"
+ if [ "$PUBLISHED_DIGEST" != "$EXPECTED_DIGEST" ]; then
+ echo "::error::The published application archive changed during repair."
+ exit 1
+ fi
+
+ gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json body \
+ > "$RUNNER_TEMP/release.json"
+ python3 - "$RUNNER_TEMP/release.json" RELEASES.md <<'PY'
+ import json
+ import sys
+ from pathlib import Path
+
+ body = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))["body"]
+ notes = Path(sys.argv[2]).read_text(encoding="utf-8")
+ if body.rstrip("\n") != notes.rstrip("\n"):
+ raise SystemExit("published GitHub release body does not match RELEASES.md")
+ PY
+ echo "Published Sparkle release notes repaired and verified for $TAG."
diff --git a/RELEASES.md b/RELEASES.md
index 9b2d4b23..19acad90 100644
--- a/RELEASES.md
+++ b/RELEASES.md
@@ -1,5 +1,3 @@
-
-
# Burrow 0.11.2
A system-metrics, updater, and launch-reliability patch. This release corrects
diff --git a/docs/macos-signing.md b/docs/macos-signing.md
index 4052a69c..bc5dd9f2 100644
--- a/docs/macos-signing.md
+++ b/docs/macos-signing.md
@@ -181,6 +181,13 @@ Choose the next app version and build number, update the release notes, and
merge the release change before tagging. Never move a tag after its GitHub
release has published.
+`RELEASES.md` is user-facing runtime content: the release workflow embeds it
+verbatim in Sparkle's signed appcast and also uses it as the GitHub release
+body. Keep only the newest release, begin directly with `# Burrow VERSION`, and
+put contributor instructions in this runbook rather than HTML comments—Sparkle
+renders those comments as visible text. Full site history belongs in
+`docs/releases.json` and is generated into `docs/releases.html`.
+
The workflow order is:
1. Require all Apple, Sparkle, and external-tap secrets, then verify that
@@ -284,6 +291,12 @@ site, not in replacement signed assets. Replace and re-sign a published feed
only for a correctness or security defect, then repeat the full verification
above.
+For that exceptional repair, first merge corrected `RELEASES.md`, then run the
+manual `repair-sparkle-release-notes` workflow with the current release tag.
+The job refuses historical or draft releases, preserves the published ZIP,
+re-signs the feed with the existing Sparkle key, verifies the embedded Markdown
+byte-for-byte, replaces `appcast.xml`, and updates the GitHub release body.
+
## Telemetry and signing
Signing and notarization do not require in-app telemetry. CI records only
diff --git a/scripts/tests/test_release_workflows.py b/scripts/tests/test_release_workflows.py
index 6416828c..dee01cf3 100644
--- a/scripts/tests/test_release_workflows.py
+++ b/scripts/tests/test_release_workflows.py
@@ -61,6 +61,36 @@ def test_release_does_not_leak_engine_credentials_into_tap_push(self) -> None:
self.assertIn('(cd "$RUNNER_TEMP" && git clone', tap_step)
self.assertIn('cd "$TAP_DIR"', tap_step)
+ def test_release_notes_are_validated_before_sparkle_embeds_them(self) -> None:
+ workflow = (WORKFLOWS / "release.yml").read_text(encoding="utf-8")
+
+ validate = workflow.index("scripts/validate-release-notes.py")
+ embed = workflow.index('cp RELEASES.md "dist/Burrow-${VERSION}.md"')
+
+ self.assertLess(validate, embed)
+
+ def test_manual_notes_repair_is_narrow_and_fail_closed(self) -> None:
+ workflow = (WORKFLOWS / "repair-sparkle-release-notes.yml").read_text(
+ encoding="utf-8"
+ )
+
+ self.assertIn("workflow_dispatch:", workflow)
+ self.assertNotIn("push:", workflow)
+ self.assertIn("contents: write", workflow)
+ self.assertIn("group: release", workflow)
+ self.assertIn("persist-credentials: false", workflow)
+ self.assertIn('if [ "$GITHUB_REF" != "refs/heads/$DEFAULT_BRANCH" ]', workflow)
+ self.assertIn("scripts/validate-release-notes.py", workflow)
+ self.assertIn("scripts/verify-sparkle-appcast.py", workflow)
+ self.assertIn("SPARKLE_ED_PRIVATE_KEY", workflow)
+ self.assertIn('gh release upload "$TAG" "$APPCAST"', workflow)
+ self.assertIn("--clobber", workflow)
+ self.assertIn('gh release edit "$TAG"', workflow)
+ self.assertIn("--notes-file RELEASES.md", workflow)
+ self.assertNotIn('gh release upload "$TAG" "$ZIP"', workflow)
+ self.assertIn('sign_update" --verify', workflow)
+ self.assertIn('if [ "$PUBLISHED_DIGEST" != "$EXPECTED_DIGEST" ]', workflow)
+
def test_xcode_27_preview_lane_is_advisory_and_runs_the_full_suite(self) -> None:
workflow = (WORKFLOWS / "ci.yml").read_text(encoding="utf-8")
start = workflow.index(" xcode-27-compatibility:")
diff --git a/scripts/tests/test_validate_release_notes.py b/scripts/tests/test_validate_release_notes.py
new file mode 100644
index 00000000..4306cf8b
--- /dev/null
+++ b/scripts/tests/test_validate_release_notes.py
@@ -0,0 +1,74 @@
+import plistlib
+import subprocess
+import tempfile
+import unittest
+from pathlib import Path
+
+
+ROOT = Path(__file__).resolve().parents[2]
+VALIDATOR = ROOT / "scripts" / "validate-release-notes.py"
+
+
+class ReleaseNotesValidationTests(unittest.TestCase):
+ def run_validator(
+ self, content: str, version: str = "1.2.3"
+ ) -> subprocess.CompletedProcess[str]:
+ with tempfile.TemporaryDirectory() as directory:
+ notes = Path(directory) / "RELEASES.md"
+ notes.write_text(content, encoding="utf-8")
+ return subprocess.run(
+ ["python3", str(VALIDATOR), str(notes), "--version", version],
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+
+ def test_accepts_single_latest_release_without_hidden_markup(self) -> None:
+ result = self.run_validator("# Burrow 1.2.3\n\nUseful release notes.\n")
+
+ self.assertEqual(result.returncode, 0, result.stderr)
+ self.assertIn("Release notes validated", result.stdout)
+
+ def test_rejects_html_comments_that_sparkle_renders_as_text(self) -> None:
+ result = self.run_validator(
+ "\n\n# Burrow 1.2.3\n\nNotes.\n"
+ )
+
+ self.assertNotEqual(result.returncode, 0)
+ self.assertIn("HTML comments", result.stderr)
+
+ def test_rejects_a_heading_that_does_not_match_the_release(self) -> None:
+ result = self.run_validator("# Burrow 1.2.4\n\nNotes.\n")
+
+ self.assertNotEqual(result.returncode, 0)
+ self.assertIn("first line", result.stderr)
+
+ def test_rejects_accumulated_top_level_release_sections(self) -> None:
+ result = self.run_validator(
+ "# Burrow 1.2.3\n\nCurrent.\n\n# Burrow 1.2.2\n\nOld.\n"
+ )
+
+ self.assertNotEqual(result.returncode, 0)
+ self.assertIn("exactly one top-level heading", result.stderr)
+
+ def test_checked_in_release_notes_are_safe_for_sparkle(self) -> None:
+ with (ROOT / "macos" / "Resources" / "Info.plist").open("rb") as stream:
+ version = plistlib.load(stream)["CFBundleShortVersionString"]
+ result = subprocess.run(
+ [
+ "python3",
+ str(VALIDATOR),
+ str(ROOT / "RELEASES.md"),
+ "--version",
+ version,
+ ],
+ text=True,
+ capture_output=True,
+ check=False,
+ )
+
+ self.assertEqual(result.returncode, 0, result.stderr)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/tests/test_verify_sparkle_appcast.py b/scripts/tests/test_verify_sparkle_appcast.py
index b67324e3..d1d532cb 100644
--- a/scripts/tests/test_verify_sparkle_appcast.py
+++ b/scripts/tests/test_verify_sparkle_appcast.py
@@ -19,6 +19,7 @@ def run_validator(
*,
url: str | None = None,
signature_output: Path | None = None,
+ release_notes: Path | None = None,
) -> subprocess.CompletedProcess[str]:
expected_url = url or (
"https://github.com/caezium/Burrow/releases/download/"
@@ -39,6 +40,8 @@ def run_validator(
]
if signature_output is not None:
command.extend(["--signature-output", str(signature_output)])
+ if release_notes is not None:
+ command.extend(["--release-notes", str(release_notes)])
return subprocess.run(
command,
capture_output=True,
@@ -135,6 +138,38 @@ def test_rejects_tampered_signed_feed_length(self) -> None:
self.assertNotEqual(result.returncode, 0)
self.assertIn("signed-feed length", result.stderr)
+ def test_requires_embedded_markdown_to_match_release_notes(self) -> None:
+ with tempfile.TemporaryDirectory() as directory:
+ root = Path(directory)
+ archive = root / "Burrow-0.11.0.zip"
+ archive.write_bytes(b"archive")
+ notes = root / "RELEASES.md"
+ notes.write_text("# Burrow 0.11.0\n\nClean notes.\n", encoding="utf-8")
+ signature = base64.b64encode(bytes(64)).decode("ascii")
+ content = (
+ f'- '
+ '21'
+ '0.11.0'
+ '\n\n# Burrow 0.11.0\n\nClean notes.\n'
+ ']]>'
+ f''
+ "
\n"
+ )
+ appcast = root / "appcast.xml"
+ appcast.write_text(
+ content
+ + "\n",
+ encoding="utf-8",
+ )
+
+ result = self.run_validator(appcast, archive, release_notes=notes)
+
+ self.assertNotEqual(result.returncode, 0)
+ self.assertIn("do not exactly match", result.stderr)
+
if __name__ == "__main__":
unittest.main()
diff --git a/scripts/validate-release-notes.py b/scripts/validate-release-notes.py
new file mode 100644
index 00000000..1c12b370
--- /dev/null
+++ b/scripts/validate-release-notes.py
@@ -0,0 +1,64 @@
+#!/usr/bin/env python3
+"""Reject release-note source that is unsafe or misleading in Sparkle UI."""
+
+from __future__ import annotations
+
+import argparse
+import sys
+from pathlib import Path
+
+
+class ValidationError(Exception):
+ pass
+
+
+def validate(path: Path, version: str) -> None:
+ try:
+ content = path.read_text(encoding="utf-8")
+ except OSError as error:
+ raise ValidationError(f"could not read release notes: {error}") from error
+
+ if "" in content:
+ raise ValidationError(
+ "HTML comments are forbidden because Sparkle renders them as visible text"
+ )
+
+ lines = content.splitlines()
+ expected_heading = f"# Burrow {version}"
+ if not lines or lines[0] != expected_heading:
+ actual = lines[0] if lines else ""
+ raise ValidationError(
+ f"first line is {actual!r}, expected {expected_heading!r}"
+ )
+
+ top_level_headings = [line for line in lines if line.startswith("# ")]
+ if len(top_level_headings) != 1:
+ raise ValidationError(
+ "release notes must contain exactly one top-level heading "
+ "(RELEASES.md is latest-only)"
+ )
+
+ if not any(line.strip() for line in lines[1:]):
+ raise ValidationError("release notes have no content after the heading")
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("notes", type=Path)
+ parser.add_argument("--version", required=True)
+ return parser.parse_args()
+
+
+def main() -> int:
+ args = parse_args()
+ try:
+ validate(args.notes, args.version)
+ except ValidationError as error:
+ print(f"release notes validation failed: {error}", file=sys.stderr)
+ return 1
+ print(f"Release notes validated for Burrow {args.version}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/verify-sparkle-appcast.py b/scripts/verify-sparkle-appcast.py
index ef3e07b2..336c14e4 100644
--- a/scripts/verify-sparkle-appcast.py
+++ b/scripts/verify-sparkle-appcast.py
@@ -41,6 +41,7 @@ def validate(
expected_version: str,
expected_build: str,
expected_url: str,
+ expected_release_notes_path: Path | None = None,
) -> str:
if not appcast_path.is_file():
raise ValidationError(f"appcast does not exist: {appcast_path}")
@@ -105,6 +106,26 @@ def validate(
f"item {name} is {actual!r}, expected {expected_value!r}"
)
+ if expected_release_notes_path is not None:
+ try:
+ expected_release_notes = expected_release_notes_path.read_text(
+ encoding="utf-8"
+ )
+ except OSError as error:
+ raise ValidationError(
+ f"could not read expected release notes: {error}"
+ ) from error
+ description = item.find("description")
+ if description is None:
+ raise ValidationError("item is missing embedded release notes")
+ if description.attrib.get(f"{{{SPARKLE_NS}}}format") != "markdown":
+ raise ValidationError("embedded release notes are not marked as markdown")
+ actual_release_notes = description.text or ""
+ if actual_release_notes != expected_release_notes:
+ raise ValidationError(
+ "embedded release notes do not exactly match the validated source"
+ )
+
archive_signature = enclosure.attrib.get(f"{{{SPARKLE_NS}}}edSignature")
if archive_signature is None:
raise ValidationError("archive enclosure is missing sparkle:edSignature")
@@ -124,6 +145,11 @@ def parse_args() -> argparse.Namespace:
type=Path,
help="write the validated archive signature for sign_update --verify",
)
+ parser.add_argument(
+ "--release-notes",
+ type=Path,
+ help="require embedded markdown to exactly match this file",
+ )
return parser.parse_args()
@@ -131,7 +157,12 @@ def main() -> int:
args = parse_args()
try:
archive_signature = validate(
- args.appcast, args.archive, args.version, args.build, args.url
+ args.appcast,
+ args.archive,
+ args.version,
+ args.build,
+ args.url,
+ args.release_notes,
)
if args.signature_output is not None:
args.signature_output.write_text(archive_signature, encoding="ascii")