Repository navigation
Publish a signed appcast with each release - #262
Conversation
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
|
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 ( Requested changes:
Not changing: a failure while copying |
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.
|
Executor reply to review round 1. Fixed in 7a1608d.
|
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.
|
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 |
|
Review round 2 (planner, with an independent Codex review): approved.
|
Closes #259
Summary
scripts/generate-appcast.pywrites one Sparkle item for the notarized DMG:sparkle:versionandsparkle:shortVersionString(the product version),sparkle:minimumSystemVersion14.0.0,pubDate, the versioned GitHub enclosure withlengthandsparkle:edSignature, Markdown release notes embedded in the signed feed in English and Simplified Chinese (xml:langen/zh-Hans, supported by Sparkle 2.10.1), andsparkle:fullReleaseNotesLinkto the GitHub Release.--criticaladdssparkle:criticalUpdate. It signs the DMG and then the feed withsign_updatefrom.build/artifacts/sparkle/Sparkle/binand checks both withsign_update --verifybefore writing.sign_updateoutput, which can echo key material, is never shown.scripts/package-release.shadds--sparkle-key-credential NAMEand--release-notes PATH(both required when notarizing) and--critical. It runs the generator through oneaskkey run --wait-for-approval --credential NAMErequest; the key path expands only in the target shell fromSPARKLE_ED_KEY_FILE.appcast.xmljoins the output names under the existing conflict checks.--no-notarizewrites no appcast and rejects the appcast options.site/_redirectsholds the single feed rule (302). ADR 0006, ADR 0011 (new Publication paragraph),docs/testing.md,site-src/README.md(Deploying) andCHANGELOG.md(Unreleased) cover publication, the read-back,releases/latest, critical updates, pulling a bad update, the later mirror, and the first-update check.Diff stat
git diff --stat origin/main...HEAD: 12 files changed, 620 insertions(+), 25 deletions(-).scripts/generate-appcast.py(168),Tests/Automation/test_generate_appcast.py(167),Tests/Automation/test_package_release_appcast.py(133),site/_redirects(1)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)Sources/,scripts/build-app.sh,.github/workflows/,scripts/sparkle-public-key.txt,scripts/notarize-release.py, every other file insite/.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 existingtest_build_app_sparkle.DebugBundleTestscases, which need a built Debug bundle; they also skip onmain.New tests, mapped to Step 6:
sign_update --verifytest_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)test_generate_appcast.GenerateAppcastTests.test_tampered_dmg_or_feed_fails_verification(also a different key)--sparkle-key-credentialfails before any outputtest_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)appcast.xmlin the output is rejectedtest_package_release.PackageReleaseTests.test_existing_stable_artifact_is_not_overwrittennow coversappcast.xmlas a file, a symlink and a dangling symlink--criticalwritessparkle:criticalUpdatetest_generate_appcast.GenerateAppcastTests.test_critical_writes_critical_updateandtest_package_release_appcast.PackageReleaseAppcastTests.test_appcast_runs_through_askkey_and_critical_writes_critical_updateOther 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). Theaskkeyinvocation 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 outputappcast.xmlwithsign_update --verifyand 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_keyswas never run. Everysign_updatecall passes--ed-key-file, so no keychain is read or written. Only the fixtureaskkeyis used. Tests that needsign_updateskip with a reason when.build/artifacts/sparkleis absent. CI resolves it withswift buildbefore 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=0python3 scripts/check_module_deps.py:Module dependency check passed.bash scripts/package-release.sh --help: lists--sparkle-key-credential NAME,--release-notes PATHand--criticalgrep -cx '/appcast.xml https://github.com/sudoHG/AskKey/releases/latest/download/appcast.xml 302' site/_redirects:1test ! -e site/appcast.xml: succeedsdocs/testing.mdwere run against a synthetic feed and key, with theaskkeystep 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.1atab7d71291238df6e0492143b120c9b696a35f603, the revision pinned inPackage.resolved. Nothing was built or run, and the clone was deleted.Downloader/SPUDownloader.m:27:SPUDownloaderadoptsNSURLSessionDownloadDelegate.:83-86: it creates the session with[NSURLSessionConfiguration defaultSessionConfiguration]and itself as delegate. It does not implementURLSession:task:willPerformHTTPRedirection:newRequest:completionHandler:; that selector appears nowhere inSparkle/orDownloader/, and "redirect" appears only in the two release-notes web views.NSURLSessiontherefore 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:209and: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:185uses it only as the base for relative appcast URLs. The feed uses absolute URLs.Sparkle/SPUDownloadDriver.m:58-60,:108-112andSparkle/SPUXPCServiceInfo.m:16-22: withoutSUEnableDownloaderServicein the Info.plist, the downloader runs in process (the sameSPUDownloaderclass).SUAppcastDriver.m:66-78: the appcast request goes through it.SUAppcastItem.mchecks only that enclosure and link URLs are http/https (:570,:625,:828,:860).curl -sIL https://github.com/sudoHG/AskKey/releases/latest/download/AskKey.dmg.sha256returns 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 byxml:langwithNSBundle preferredLocalizationsFromArray:.SUUpdateAlert.m:181-195,:333-339:sparkle:format="markdown"descriptions are rendered bySUTextViewReleaseNotesView, not a web view.SPUStandardUserDriver.m:679-685:fullReleaseNotesLinkis only opened in the browser from Version History.sign_update/main.swift:54-86,:177-181:--ed-key-filereads 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 makessign_updateprint 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
<!-- lang: en -->and<!-- lang: zh-Hans -->, not the GitHub notes'## English/ Chinese heading.scripts/check_hygiene.pyrejects CJK text in Markdown outside its allowlist, and it is outside Scope. The docs would otherwise have to spell the Chinese heading.**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.CFBundleVersionmust equal the product version when notarizing. The generator writes the product version assparkle:version, and the issue requires that to equalCFBundleVersion.generate-appcast.py --check-release-notesruns before any notarization request, so a malformed notes file does not cost a notarization round.AskKey-<version>.dmg, the file the enclosure URL names.--no-notarize, like the existing--notary-credentialconflict.--disable-signing-warning. The signed bytes are exactly the generated XML; the trailingsparkle-signaturesblock already marks the file as signed. The default would re-serialize the document throughNSXMLDocumentfirst.test_package_release.pywould pass 600 lines, so the new packaging tests are intest_package_release_appcast.py. That file loadstest_package_release.pywithimportliband usesPackageReleaseTests()as a harness by composition, so the existing tests do not run twice.sign_update --verifychecks against the public key derived from the delivered private key. The generator now also checks the DMG signature againstscripts/sparkle-public-key.txtwith CryptoKit (/usr/bin/xcrun swift), and fails before writing anything on a mismatch.--public-key-fileexists for tests and is never passed by packaging; the fixtureaskkeyappends it for the synthetic pair.