Skip to content

Publish a signed appcast with each release - #262

Merged
sudoHG merged 3 commits into
mainfrom
sudoHG/issue-259-signed-appcast-01f92b
Nov 5, 2026
Merged

sudoHG merged 3 commits into
mainfrom
sudoHG/issue-259-signed-appcast-01f92b

Conversation

@sudoHG

@sudoHG sudoHG commented Nov 5, 2026 •

Copy link
Copy Markdown
Owner

Closes #259

Summary

  • New scripts/generate-appcast.py writes one Sparkle item for the notarized DMG: sparkle:version and sparkle:shortVersionString (the product version), sparkle:minimumSystemVersion 14.0.0, pubDate, the versioned GitHub enclosure with length and sparkle:edSignature, Markdown release notes embedded in the signed feed in English and Simplified Chinese (xml:lang en / zh-Hans, supported by Sparkle 2.10.1), and sparkle:fullReleaseNotesLink to the GitHub Release. --critical adds sparkle:criticalUpdate. It signs the DMG and then the feed with sign_update from .build/artifacts/sparkle/Sparkle/bin and checks both with sign_update --verify before writing. sign_update output, which can echo key material, is never shown.
  • scripts/package-release.sh adds --sparkle-key-credential NAME and --release-notes PATH (both required when notarizing) and --critical. It runs the generator through one askkey run --wait-for-approval --credential NAME request; the key path expands only in the target shell from SPARKLE_ED_KEY_FILE. appcast.xml joins the output names under the existing conflict checks. --no-notarize writes no appcast and rejects the appcast options.
  • site/_redirects holds the single feed rule (302). ADR 0006, ADR 0011 (new Publication paragraph), docs/testing.md, site-src/README.md (Deploying) and CHANGELOG.md (Unreleased) cover publication, the read-back, releases/latest, critical updates, pulling a bad update, the later mirror, and the first-update check.
  • Step 4: Sparkle 2.10.1 follows HTTPS redirects across hosts and nothing in it refuses them (citations under Checks).

Diff stat

git diff --stat origin/main...HEAD: 12 files changed, 620 insertions(+), 25 deletions(-).

  • New: scripts/generate-appcast.py (168), Tests/Automation/test_generate_appcast.py (167), Tests/Automation/test_package_release_appcast.py (133), site/_redirects (1)
  • Changed: scripts/package-release.sh (+51/-5), Tests/Automation/test_package_release.py (+40/-5, now 566 lines), Tests/Automation/Fixtures/package-release-tool.py (+19/-7), docs/testing.md (+29/-4), docs/adr/0006-release-process.md (+6/-2), docs/adr/0011-in-app-updates.md (+4), site-src/README.md (+2), CHANGELOG.md (+2)
  • Not touched: Sources/, scripts/build-app.sh, .github/workflows/, scripts/sparkle-public-key.txt, scripts/notarize-release.py, every other file in site/.

Tests

python3 -m unittest discover -s Tests/Automation -v: 256 run, 253 passed, 0 failed, 3 skipped (after review round 1). main (18d195c) collects 244 tests; this branch adds 12 and removes none. The 3 skips are the existing test_build_app_sparkle.DebugBundleTests cases, which need a built Debug bundle; they also skip on main.

New tests, mapped to Step 6:

Step 6 item Test
Synthetic Ed25519 key file in a temporary directory, no keychain item, feed accepted by sign_update --verify test_generate_appcast.GenerateAppcastTests.test_synthetic_key_produces_a_feed_that_sign_update_verifies (also checks every item field and that the enclosure signature verifies the DMG)
Tampered DMG or feed fails test_generate_appcast.GenerateAppcastTests.test_tampered_dmg_or_feed_fails_verification (also a different key)
Notarizing without --sparkle-key-credential fails before any output test_package_release_appcast.PackageReleaseAppcastTests.test_notarizing_without_appcast_options_fails_before_work (also missing or invalid --release-notes; no tool call, no output, empty scratch)
Existing appcast.xml in the output is rejected test_package_release.PackageReleaseTests.test_existing_stable_artifact_is_not_overwritten now covers appcast.xml as a file, a symlink and a dangling symlink
--critical writes sparkle:criticalUpdate test_generate_appcast.GenerateAppcastTests.test_critical_writes_critical_update and test_package_release_appcast.PackageReleaseAppcastTests.test_appcast_runs_through_askkey_and_critical_writes_critical_update

Other new tests: test_key_that_does_not_match_the_public_key_writes_nothing (review round 1: a valid synthetic key against the committed public key fails and writes nothing), test_invalid_release_notes_are_rejected_before_signing, test_invalid_key_is_never_printed_and_writes_nothing, test_existing_output_or_misnamed_dmg_is_rejected (generator); test_appcast_options_conflict_with_no_notarize, test_bundle_version_mismatch_is_rejected_before_work, test_appcast_failures_write_nothing (approval denial, an invalid key and a mismatched key: nothing written, key text never printed). The askkey invocation test checks the exact arguments, the target shell (--ed-key-file "$SPARKLE_ED_KEY_FILE", under 512 bytes), and that neither the key path nor the key text appears in any argument or output. Both existing success-path packaging tests now verify the output appcast.xml with sign_update --verify and its enclosure signature against the output DMG.

Synthetic keys are base64(os.urandom(32)) seed files in temporary directories, with the matching public key derived by CryptoKit. generate_keys was never run. Every sign_update call passes --ed-key-file, so no keychain is read or written. Only the fixture askkey is used. Tests that need sign_update skip with a reason when .build/artifacts/sparkle is absent. CI resolves it with swift build before the Automation step.

Checks

  • python3 scripts/check_hygiene.py: Hygiene rules passed: size=0, test-support=0, debug=0, local-path=0, non-ascii-name=0, multica=0, cjk=0
  • python3 scripts/check_module_deps.py: Module dependency check passed.
  • bash scripts/package-release.sh --help: lists --sparkle-key-credential NAME, --release-notes PATH and --critical
  • grep -cx '/appcast.xml https://github.com/sudoHG/AskKey/releases/latest/download/appcast.xml 302' site/_redirects: 1
  • test ! -e site/appcast.xml: succeeds
  • The read-back commands added to docs/testing.md were run against a synthetic feed and key, with the askkey step replaced by its inner shell: signature verified, version matched, enclosure length matched.

Step 4: Sparkle 2.10.1 redirect handling. Read from a shallow clone of tag 2.10.1 at ab7d71291238df6e0492143b120c9b696a35f603, the revision pinned in Package.resolved. Nothing was built or run, and the clone was deleted.

  • Downloader/SPUDownloader.m:27: SPUDownloader adopts NSURLSessionDownloadDelegate. :83-86: it creates the session with [NSURLSessionConfiguration defaultSessionConfiguration] and itself as delegate. It does not implement URLSession:task:willPerformHTTPRedirection:newRequest:completionHandler:; that selector appears nowhere in Sparkle/ or Downloader/, and "redirect" appears only in the two release-notes web views. NSURLSession therefore follows redirects with its default behaviour.
  • SPUDownloader.m:65-81: the http/https scheme check applies only to the initial request URL.
  • SPUDownloader.m:191-205, called at :209 and :246: the status check rejects only status < 200 or ≥ 400 on the final response.
  • SPUDownloader.m:214-224: the response URL after redirects becomes the download URL. Sparkle/SUAppcastDriver.m:185 uses it only as the base for relative appcast URLs. The feed uses absolute URLs.
  • Sparkle/SPUDownloadDriver.m:58-60, :108-112 and Sparkle/SPUXPCServiceInfo.m:16-22: without SUEnableDownloaderService in the Info.plist, the downloader runs in process (the same SPUDownloader class). SUAppcastDriver.m:66-78: the appcast request goes through it.
  • No host checks: SUAppcastItem.m checks only that enclosure and link URLs are http/https (:570, :625, :828, :860).
  • Live check of the GitHub leg, without the website hop: curl -sIL https://github.com/sudoHG/AskKey/releases/latest/download/AskKey.dmg.sha256 returns 302 → github.com/.../download/v0.5.0/..., then 302 → release-assets.githubusercontent.com, then 200. Every hop is HTTPS.

Also relevant to the feed contents, from the same clone:

  • SUAppcast.m:237-276: localized nodes are chosen by xml:lang with NSBundle preferredLocalizationsFromArray:.
  • SUUpdateAlert.m:181-195, :333-339: sparkle:format="markdown" descriptions are rendered by SUTextViewReleaseNotesView, not a web view.
  • SPUStandardUserDriver.m:679-685: fullReleaseNotesLink is only opened in the browser from Version History.
  • sign_update/main.swift:54-86, :177-181: --ed-key-file reads the key from a file. The keychain is used only when no key file is given.
  • sign_update/main.swift:71: an undecodable key file makes sign_update print the key text to stdout. This is why the generator never shows its output.

CI at the time of opening: 3 checks pending (build-and-test, basic-ui-flows, release-sdk-window-check); the planner watches the run. Locally all Acceptance commands pass as above.

Deviations and questions

  1. Release-notes markers. The two sections start with <!-- lang: en --> and <!-- lang: zh-Hans -->, not the GitHub notes' ## English / Chinese heading. scripts/check_hygiene.py rejects CJK text in Markdown outside its allowlist, and it is outside Scope. The docs would otherwise have to spell the Chinese heading.
  2. ADR 0011 had no Publication section. I added a **Publication.** paragraph and a redirect paragraph after **Packaging.**. I did not edit decision 8, which still says "Publish a signed appcast with each release #259 decides the appcast contents"; the new paragraph answers it.
  3. When the first-update check runs. It is in the pre-release checklist, but the feed goes live only when the release is published, and the app accepts no other feed. The check therefore runs right after publishing and the read-back, and a failure leads to pulling the update.
  4. Checks the issue did not list, needed to meet its requirements.
    • The app's CFBundleVersion must equal the product version when notarizing. The generator writes the product version as sparkle:version, and the issue requires that to equal CFBundleVersion.
    • generate-appcast.py --check-release-notes runs before any notarization request, so a malformed notes file does not cost a notarization round.
    • The generator only signs a DMG named AskKey-<version>.dmg, the file the enclosure URL names.
    • The appcast options conflict with --no-notarize, like the existing --notary-credential conflict.
  5. Order. The appcast is generated after the output conflict check and before any output is written. A conflicting output directory therefore fails before the Sparkle approval card.
  6. --disable-signing-warning. The signed bytes are exactly the generated XML; the trailing sparkle-signatures block already marks the file as signed. The default would re-serialize the document through NSXMLDocument first.
  7. Test layout. test_package_release.py would pass 600 lines, so the new packaging tests are in test_package_release_appcast.py. That file loads test_package_release.py with importlib and uses PackageReleaseTests() as a harness by composition, so the existing tests do not run twice.
  8. Resolved in review round 1: key mismatch. sign_update --verify checks against the public key derived from the delivered private key. The generator now also checks the DMG signature against scripts/sparkle-public-key.txt with CryptoKit (/usr/bin/xcrun swift), and fails before writing anything on a mismatch. --public-key-file exists for tests and is never passed by packaging; the fixture askkey appends it for the synthetic pair.

scripts/generate-appcast.py writes one Sparkle item for the notarized DMG:
sparkle:version and shortVersionString, minimumSystemVersion 14.0.0,
pubDate, the versioned GitHub enclosure with length and edSignature,
embedded Markdown release notes in English and Simplified Chinese
(xml:lang), and a fullReleaseNotesLink to the GitHub Release. --critical
adds sparkle:criticalUpdate. It signs the DMG and then the feed with
sign_update from the resolved SwiftPM artifact and verifies both before
writing; sign_update output is never shown.

package-release.sh requires --sparkle-key-credential and --release-notes
when notarizing, checks them and CFBundleVersion before any notarization
request, runs the generator through one askkey run --wait-for-approval
request with the key delivered as SPARKLE_ED_KEY_FILE, and adds
appcast.xml to the output names under the existing conflict checks.
--no-notarize runs write no appcast and reject the appcast options.

site/_redirects sends /appcast.xml to the latest release's appcast with a
302. ADR 0006, ADR 0011 (Publication), docs/testing.md, site-src/README.md
and CHANGELOG.md describe publication, the read-back, critical updates,
pulling a bad update and the first-update check.

Closes #259
@luoji-bot

luoji-bot Bot commented Nov 5, 2026

Copy link
Copy Markdown

Review round 1 (planner, with an independent Codex review). No key-exposure path and no Scope violation found. Confirmed against Sparkle 2.10.1 source: Markdown descriptions are supported (SUAppcastItem.m:533) and xml:lang nodes are chosen by the user's preferred languages (SUAppcast.m:236-268).

Requested changes:

  1. Must fix. Every check verifies against the public key derived from the delivered private key, so a valid but wrong key passes packaging and read-back while every installed copy rejects the feed. Before writing the feed, verify the DMG signature against scripts/sparkle-public-key.txt with CryptoKit through xcrun swift (public key, signature and DMG only), fail with no output on a mismatch, and add a test for a valid but mismatched key.
  2. Small fix. In the read-back commands in docs/testing.md, make every check print an explicit OK or FAILED line, so a failed check cannot read as success.

Not changing: a failure while copying appcast.xml into the output directory can leave partial outputs. This needs a disk or I/O failure at that exact step, and the existing four DMG outputs behave the same way.

sign_update --verify derives the public key from the delivered private
key, so a key that does not match the app's SUPublicEDKey used to pass
packaging. generate-appcast.py now also checks the DMG signature against
scripts/sparkle-public-key.txt with CryptoKit through /usr/bin/xcrun
swift, which receives only the public key, the signature and the DMG
path, and fails before writing anything on a mismatch.
--public-key-file exists for tests; packaging never passes it, and the
fixture askkey appends it to point at the synthetic pair.

The read-back commands in docs/testing.md now print OK or FAILED for
every check, including both downloads.
@luoji-bot

luoji-bot Bot commented Nov 5, 2026

Copy link
Copy Markdown

Executor reply to review round 1. Fixed in 7a1608d.

  1. Fixed: the signature is checked against the committed public key. After sign_update signs the DMG and verifies it, scripts/generate-appcast.py checks that signature against scripts/sparkle-public-key.txt with CryptoKit (Curve25519.Signing.PublicKey(rawRepresentation:) and isValidSignature(_:for:)). It runs a Swift snippet embedded in the generator through /usr/bin/xcrun swift. The snippet is written to the generator's private temporary directory and receives only the public key, the signature and the DMG path. SPARKLE_ED_KEY_FILE is removed from its environment, and TMPDIR points into that same temporary directory, because the Swift interpreter leaves empty TemporaryDirectory.* folders behind. A mismatch fails with The delivered Sparkle key does not match the repository public key before the feed is built, and nothing is written. A malformed public key file, or a failure to run Swift, also fails closed.
    • --public-key-file defaults to scripts/sparkle-public-key.txt. package-release.sh never passes it, so packaging has no override for this check. In the packaging tests, the fixture askkey appends --public-key-file with the synthetic public key to the Sparkle target command. The recorded call, which the invocation test asserts, is the one packaging actually built.
    • The synthetic pairs are a random 32-byte seed, as before, with the public key derived by CryptoKit. One xcrun swift call per test module is cached.
    • New: test_generate_appcast.GenerateAppcastTests.test_key_that_does_not_match_the_public_key_writes_nothing. It uses a valid synthetic key with the default committed public key: the run fails with the mismatch message, writes no output, leaves the scratch directory empty, and never prints the key. A malformed and a missing public key file are covered as subtests.
    • Extended: test_package_release_appcast.PackageReleaseAppcastTests.test_appcast_failures_write_nothing gains a mismatched-key case at the packaging level. All existing success paths now use matching pairs.
    • Docs: one sentence each in docs/testing.md (Release packaging) and ADR 0011 (Publication).
  2. Fixed: the read-back prints OK or FAILED for every check. Each step in docs/testing.md now ends in && echo "OK: ..." || echo "FAILED: ...". That covers both curl downloads, the signature, the version and the enclosure length; the length check also fails when the feed has no length. There is no exit and no set -e. I ran the lines against a synthetic feed for a good case, a wrong version with a missing enclosure, and a missing feed. Every check printed its own line, and nothing exited early.
  3. Not changed: the planner decided not to fix this, because it is niche and the existing outputs behave the same way.

python3 -m unittest discover -s Tests/Automation -v: 256 run, 253 passed, 0 failed, 3 skipped. The skips are the existing DebugBundleTests, which need a built Debug bundle. python3 scripts/check_hygiene.py passed with every rule at 0, and python3 scripts/check_module_deps.py passed. The other Acceptance commands are unchanged and pass.

The embedded Swift check exited with 1 on a signature mismatch, which is
also the exit code of a Swift compile failure, so a toolchain problem
would have been reported as a mismatched Sparkle key. A mismatch now
exits with 3, and every other non-zero code reports that the signature
could not be checked. Also fix the spacing of the SPARKLE constant.
@luoji-bot

luoji-bot Bot commented Nov 5, 2026

Copy link
Copy Markdown

Executor follow-up after review round 2, in e529ed5. A signature mismatch now exits the embedded Swift check with a dedicated code, 3, because a Swift compile failure also exits with 1. Only 3 maps to The delivered Sparkle key does not match the repository public key; every other non-zero code reports Cannot check the DMG signature against the public key. I also fixed the spacing in SPARKLE = "http://...". Tests are unchanged: they assert the generator's own exit code, not Swift's. python3 -m unittest discover -s Tests/Automation -v: 256 run, 253 passed, 0 failed, 3 skipped (the existing DebugBundleTests). python3 scripts/check_hygiene.py passed with every rule at 0.

@luoji-bot

luoji-bot Bot commented Nov 5, 2026

Copy link
Copy Markdown

Review round 2 (planner, with an independent Codex review): approved.

  • The DMG signature is now verified against scripts/sparkle-public-key.txt with CryptoKit before the feed or any output is written; the Swift check never receives the private key, and a mismatch has its own exit code, so a Swift toolchain failure is not reported as a key mismatch.
  • The read-back commands in docs/testing.md print an explicit OK: or FAILED: line for every check.
  • No new issues found. Recommended for merge once both required CI jobs pass on e529ed5. After the merge, the planner deploys the website with the maintainer's approval and checks the /appcast.xml redirect.

@sudoHG
sudoHG merged commit 0150549 into main Nov 5, 2026
3 checks passed
@sudoHG
sudoHG deleted the sudoHG/issue-259-signed-appcast-01f92b branch November 5, 2026 15:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Publish a signed appcast with each release

1 participant