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")