Skip to content

Move the Sparkle feed to a release asset and split release signing from the build - #2259

Open
r3dbars wants to merge 9 commits into
mainfrom
claude/issue-2201-release-feed
Open

r3dbars wants to merge 9 commits into
mainfrom
claude/issue-2201-release-feed

Conversation

@r3dbars

@r3dbars r3dbars commented Oct 18, 2026

Copy link
Copy Markdown
Owner

Implements the owner's decision on #2201 (release hardening, remaining part after #2227 / #2255): the Sparkle feed moves to a GitHub release asset, the old docs/appcast.xml gets one final entry and is then frozen, and the Release Candidate workflow splits build from signing.

Nothing was published, uploaded, dispatched or frozen. No release entry was written into docs/appcast.xml.

What changed

1. Feed off main

  • Info.plist: SUFeedURL is now https://github.com/r3dbars/transcripted/releases/latest/download/appcast.xml. SUVerifyUpdateBeforeExtraction is on.
  • generate-sparkle-appcast.sh has no default feed path. It merges into a scratch copy (REPO_APPCAST_PATH), sets the feed's self link to the new URL, and refuses docs/appcast.xml once it's frozen.
  • New scripts/release/sign-appcast.sh: embeds the EdDSA feed signature with Sparkle's sign_update (key on stdin, never on disk), then verifies it with the same key. It's always the last edit.
  • New scripts/release/fetch-sparkle-tools.sh: pinned, sha256-checked Sparkle tools, so the signing job doesn't run build-deps.sh.
  • The workflow merges the new item into the previous release's appcast.xml asset. The first release on the new feed starts from docs/appcast.xml. After the freeze, a latest release without an appcast.xml fails the run instead of quietly starting over from the legacy file.
  • verify-sparkle-release.sh fetches the live feed through the redirect. post-dmg-release-audit.py takes --appcast.
  • Redirects: releases/latest/download/appcast.xml is a 302 to releases/download/v<latest>/appcast.xml, then another 302 to release-assets.githubusercontent.com. I checked this with curl -sIL against 1.1.72. Sparkle fetches through URLSession, which follows redirects by default, and the app doesn't override that. Every shipped update already downloads its DMG through that second hop. The feed fetch and the extra latest hop haven't been run through a real Sparkle build yet.
  • Betas: there's no Sparkle beta channel today (no allowedChannels, no channel tags). releases/latest skips prereleases, so a shipping release must never be marked prerelease. The docs say so. A later beta channel can go in the same feed with sparkle:channel.
  • Signed-feed enforcement (SURequireSignedFeed) is not turned on. Sparkle 2.9.1 (pinned) supports it. But Sparkle's docs say external release notes must be signed too when it's on, and every item's sparkle:releaseNotesLink points at a GitHub release page, which can't be signed. Sparkle doesn't document what happens then, so enabling it now could break the update window on the first build that has it. The feed is still signed every release. Turning the key on needs embedded or static signed release notes plus the staged upgrade test, and it can land in any later release. Details are in docs/sparkle-updates.md under "Signed feed".

2. Legacy feed freeze

  • New scripts/release/freeze-legacy-appcast.py, run once for the first new-feed release. It copies that release's signed item to the top of docs/appcast.xml and writes config/release/legacy-appcast-freeze.json with the file's sha256. --mark-critical and --dry-run are optional. It refuses if the freeze file already exists, if the item doesn't match the Info.plist version, if that plist doesn't carry the new SUFeedURL, or if the item is unsigned.
  • check-known-traps.py gets a new legacy-appcast check, which runs in CI through linux-checks.sh. Once frozen, it fails if docs/appcast.xml doesn't match the recorded hash, if the record differs from origin/main, or if any commit after the freeze commit touches either file. The history part also catches an edit pushed straight to main.

3. Build/sign split (release-candidate.yml)

  • build-unsigned: no environment, no secrets. Runs build-beta.sh with TRANSCRIPTED_UNSIGNED_APP_ONLY=1, which stops after the app, the dSYM and the performance budget, then uploads the unsigned app.
  • sign-and-notarize: the release environment, and the only job that holds secrets. It requires a dispatch from main and checks out main, not the prep branch. It runs only sign-release.sh, notarytool, fetch-sparkle-tools.sh, generate-sparkle-appcast.sh, sign-appcast.sh and the cask edit. No build*.sh, no swift.
  • verify-candidate: no secrets. It opens the signed DMG and runs packaged-app-smoke, the privacy sweep and the release audit. Only a pass produces the -release-candidate artifact.
  • What made the split possible: build-beta.sh used to sign inline. Its signing and DMG functions moved unchanged into scripts/entrypoints/lib/release-package.sh, which both build-beta.sh and the new scripts/release/sign-release.sh source. Local build-beta.sh behaves as before.

Tests: Tests/CIWorkflowContractTests.swift checks the job split, that the build and verify jobs have no secrets or environment, that the signing job runs no build scripts, the post-freeze guard, and that no job runs a publish or commit command. Tests/ReleaseMetadataContractTests.swift checks the new SUFeedURL and SUVerifyUpdateBeforeExtraction, that the legacy feed keeps its own self link, and that it knows about the freeze. Also new: SparkleAppcastSigningTests.sh, and new cases in SparkleAppcastDeltaTests.sh. No test reads Sources/.

Docs: docs/sparkle-updates.md (Feed hosting, Signed feed, Legacy feed, Not done yet), docs/release-packaging.md, AGENTS.md Releases, scripts/release/AGENTS.md, and a few more.

One-time steps for the owner at the first release on the new feed

Until step 5 lands on main, every installed build keeps reading docs/appcast.xml from main and sees no update. Merging this PR changes nothing for installed apps. The new SUFeedURL only reaches users inside a release built from it.

  1. Merge this. Branch the release prep off origin/main and run bump-release-version.py. Dispatch with gh workflow run release-candidate.yml --ref main -f source_ref=<prep branch> -f version=<x.y.z>, and decide -f mark_critical_for_old_builds=true|false (it marks the new item critical for 1.1.22-1.1.62). Approve the release environment.
  2. Download the Transcripted-<x.y.z>-release-candidate artifact. Check that build/appcast.xml ends with a sparkle-signatures block and that its newest item is <x.y.z>.
  3. Publish as a draft first, then flip it to latest, so releases/latest never points at a release without a feed:
    gh release create v<x.y.z> --draft --target <source_ref sha> build/Transcripted-<x.y.z>.dmg build/appcast.xml $(find build/sparkle-deltas -name '*.delta')
    then gh release edit v<x.y.z> --draft=false --latest. Never mark it as a prerelease.
  4. bash scripts/release/verify-sparkle-release.sh <x.y.z> (fetches the live feed through the redirect).
  5. Freeze the legacy feed, once: from a checkout whose Info.plist is the release's,
    python3 scripts/release/freeze-legacy-appcast.py --new-appcast build/appcast.xml --info-plist Info.plist --dry-run, then run it again without --dry-run. Add --mark-critical if you want builds 1.1.22-1.1.62 to get the update window. Commit docs/appcast.xml and config/release/legacy-appcast-freeze.json together with the version bump, cask and release-health fixture, and land them on main. That push is what sends old installs to this build. After that, check-known-traps.py blocks any further edit to docs/appcast.xml.
  6. Take an old install through the hop: old build, then this build, then the next release on the new feed. docs/staged-sparkle-update-test.md covers the staged test. Then point the website's /appcast.xml route at the release-asset feed.

What's left

  • Not run: a real Release Candidate, a Sparkle upgrade through the hop, a clean-machine feed and redirect check, and sign-release.sh with the real Developer ID. Nothing has executed the workflow YAML under Actions; it parses (Ruby YAML) and the contract tests pass.
  • Before the second new-feed release: nightly-security-check.py live surfaces, release-health-card.py and release-watch.py still read the "latest release" from docs/appcast.xml, which will sit on the frozen entry. They need to read the release-asset feed. The nightly check skips the new feed URL until the freeze record exists, so it doesn't report a 404.
  • SURequireSignedFeed (see above). The owner decides how release notes should work.
  • QA bench --mode full was not run. It publishes nothing, but it runs the live mic/system-audio capture smoke and the pasteback smoke, which sends synthetic Cmd+V through the real clipboard. Other agent sessions were building on this Mac at the same time. None of its steps exercise the release workflow or the signing scripts, which is where this diff lives. Please run it on a quiet machine before the first release from this.

Checks

  • bash check.sh (run on the final head, clean tree): 57 of 58 mapped checks pass (build-deps, build.sh --no-open, run-tests.sh, swift test for Tools/TranscriptedQA, check-known-traps, doc paths, source pins, module boundaries, file size, test shape, nightly strict, appcast delta and signing tests, freeze and critical self-tests). One BLOCKED: SKIP_NOTARIZATION=1 bash build-beta.sh '' <user-name> is a placeholder command needing an operator name; the Sonnet pass ran it by hand (headless, and with TRANSCRIPTED_UNSIGNED_APP_ONLY=1), I did not rerun it.
  • bash run-tests.sh --filter testCIWorkflowContract and --filter testReleaseMetadataContract: 82 of 82 and 35 of 35 pass
  • Pass: SparkleAppcastDeltaTests.sh, SparkleAppcastSigningTests.sh, freeze-legacy-appcast.py --self-test, mark-appcast-critical.py --self-test, check-known-traps.py --self-test and the full check, test-nightly-security-check.py, packaged-app-smoke.py --self-test, check-source-pins.py --changed-only, check-test-shape.py.
  • Real Sparkle 2.9.1 sign_update with a random throwaway key: sign-appcast.sh signed a copy of the current feed. Re-signing kept one signature block and one warning. Verify passes with the right key, fails with a wrong key, and fails on a tampered feed.
  • Workflow YAML parses (Ruby). Diff and PR text checked for private research references and personal names: clean.

Review

Opus reviewer over the Sonnet diff vs origin/main (plus a merge of current main, with the AGENTS.md Releases conflict resolved to keep main's "don't touch the appcast or cask outside a release" rule and fit it to the new feed). Verdict: good to merge after owner review. The owner's decision is implemented as stated, with the gaps above. The signing helpers in release-package.sh are byte-identical moves of the build-beta.sh functions. No secret or environment reaches a job that compiles or runs repo Swift code. No baseline grew, no check was loosened, and no test reads Sources/. Fixes made in review:

  • added the post-freeze history check, so a direct push to main that rewrites both the feed and the record is still caught
  • the workflow fails, instead of restarting from the legacy file, when the latest release has no feed after the freeze
  • packaged-app-smoke.py kept its self-link and version checks for the generated feed; before, it skipped them for any feed once SUFeedURL moved
  • documented why SURequireSignedFeed stays off, the redirect chain as observed, and that the freeze must land with the version bump (otherwise ReleaseMetadataContractTests go red)

🤖 Generated with Claude Code

…signing

SUFeedURL now reads the appcast.xml asset of the latest release. The release
candidate workflow builds an unsigned app in a job with no secrets, signs and
notarizes in the release-environment job (signing scripts only, from main),
then verifies the signed output in a third job with no secrets. The feed is
merged from the previous release's appcast.xml, marked critical on request, and
signed with sign_update as the last edit.

docs/appcast.xml becomes a legacy feed: freeze-legacy-appcast.py writes its
final entry once, for the first release on the new feed, and check-known-traps
fails on any later edit. Nothing is frozen yet and nothing was published.
- check-known-traps: also fail on any commit after the freeze commit that
  touches docs/appcast.xml or the freeze record, so an edit pushed straight
  to main is caught even though origin/main already holds it.
- Release Candidate: once the legacy feed is frozen, a latest release with
  no appcast.xml fails the run instead of quietly restarting from
  docs/appcast.xml and dropping every release since the freeze.
- packaged-app-smoke.py: keep the self-link and version checks when it is
  handed the generated feed; skip them only for the legacy file.
- Docs: SURequireSignedFeed exists in Sparkle 2.9.1 but stays off until
  release notes stop pointing at unsignable GitHub release pages; the freeze
  lands with that release's version bump and cask.
@r3dbars r3dbars added the needs owner review Owner must review before merge label Oct 18, 2026
r3dbars and others added 3 commits October 26, 2026 05:26
live_appcast_urls skipped the release-asset SUFeedURL whenever root was
omitted, so the no-root unit test (and Swift CI's NightlySecurityContractTests)
failed after Info.plist moved off transcripted.app/appcast.xml. Skip that
unpublished URL only when a checkout is passed and the freeze record is
missing. The default list still names every feed the app uses.

Co-authored-by: Justin Betker <tz427gsydr@privaterelay.appleid.com>
Co-authored-by: Justin Betker <tz427gsydr@privaterelay.appleid.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs owner review Owner must review before merge risk:high

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant