Repository navigation
Conversation
…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.
…ease-feed # Conflicts: # AGENTS.md
- 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.
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.xmlgets 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:SUFeedURLis nowhttps://github.com/r3dbars/transcripted/releases/latest/download/appcast.xml.SUVerifyUpdateBeforeExtractionis on.generate-sparkle-appcast.shhas no default feed path. It merges into a scratch copy (REPO_APPCAST_PATH), sets the feed's self link to the new URL, and refusesdocs/appcast.xmlonce it's frozen.scripts/release/sign-appcast.sh: embeds the EdDSA feed signature with Sparkle'ssign_update(key on stdin, never on disk), then verifies it with the same key. It's always the last edit.scripts/release/fetch-sparkle-tools.sh: pinned, sha256-checked Sparkle tools, so the signing job doesn't runbuild-deps.sh.appcast.xmlasset. The first release on the new feed starts fromdocs/appcast.xml. After the freeze, a latest release without anappcast.xmlfails the run instead of quietly starting over from the legacy file.verify-sparkle-release.shfetches the live feed through the redirect.post-dmg-release-audit.pytakes--appcast.releases/latest/download/appcast.xmlis a 302 toreleases/download/v<latest>/appcast.xml, then another 302 torelease-assets.githubusercontent.com. I checked this withcurl -sILagainst 1.1.72. Sparkle fetches throughURLSession, 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 extralatesthop haven't been run through a real Sparkle build yet.allowedChannels, no channel tags).releases/latestskips prereleases, so a shipping release must never be marked prerelease. The docs say so. A later beta channel can go in the same feed withsparkle:channel.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'ssparkle:releaseNotesLinkpoints 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 indocs/sparkle-updates.mdunder "Signed feed".2. Legacy feed freeze
scripts/release/freeze-legacy-appcast.py, run once for the first new-feed release. It copies that release's signed item to the top ofdocs/appcast.xmland writesconfig/release/legacy-appcast-freeze.jsonwith the file's sha256.--mark-criticaland--dry-runare optional. It refuses if the freeze file already exists, if the item doesn't match theInfo.plistversion, if that plist doesn't carry the newSUFeedURL, or if the item is unsigned.check-known-traps.pygets a newlegacy-appcastcheck, which runs in CI throughlinux-checks.sh. Once frozen, it fails ifdocs/appcast.xmldoesn't match the recorded hash, if the record differs fromorigin/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. Runsbuild-beta.shwithTRANSCRIPTED_UNSIGNED_APP_ONLY=1, which stops after the app, the dSYM and the performance budget, then uploads the unsigned app.sign-and-notarize: thereleaseenvironment, and the only job that holds secrets. It requires a dispatch from main and checks out main, not the prep branch. It runs onlysign-release.sh, notarytool,fetch-sparkle-tools.sh,generate-sparkle-appcast.sh,sign-appcast.shand the cask edit. Nobuild*.sh, noswift.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-candidateartifact.build-beta.shused to sign inline. Its signing and DMG functions moved unchanged intoscripts/entrypoints/lib/release-package.sh, which bothbuild-beta.shand the newscripts/release/sign-release.shsource. Localbuild-beta.shbehaves as before.Tests:
Tests/CIWorkflowContractTests.swiftchecks 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.swiftchecks the newSUFeedURLandSUVerifyUpdateBeforeExtraction, that the legacy feed keeps its own self link, and that it knows about the freeze. Also new:SparkleAppcastSigningTests.sh, and new cases inSparkleAppcastDeltaTests.sh. No test readsSources/.Docs:
docs/sparkle-updates.md(Feed hosting, Signed feed, Legacy feed, Not done yet),docs/release-packaging.md,AGENTS.mdReleases,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.xmlfrom main and sees no update. Merging this PR changes nothing for installed apps. The newSUFeedURLonly reaches users inside a release built from it.origin/mainand runbump-release-version.py. Dispatch withgh 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 thereleaseenvironment.Transcripted-<x.y.z>-release-candidateartifact. Check thatbuild/appcast.xmlends with asparkle-signaturesblock and that its newest item is<x.y.z>.releases/latestnever 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.bash scripts/release/verify-sparkle-release.sh <x.y.z>(fetches the live feed through the redirect).Info.plistis 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-criticalif you want builds 1.1.22-1.1.62 to get the update window. Commitdocs/appcast.xmlandconfig/release/legacy-appcast-freeze.jsontogether 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.pyblocks any further edit todocs/appcast.xml.docs/staged-sparkle-update-test.mdcovers the staged test. Then point the website's/appcast.xmlroute at the release-asset feed.What's left
sign-release.shwith the real Developer ID. Nothing has executed the workflow YAML under Actions; it parses (Ruby YAML) and the contract tests pass.nightly-security-check.pylive surfaces,release-health-card.pyandrelease-watch.pystill read the "latest release" fromdocs/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.--mode fullwas 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 withTRANSCRIPTED_UNSIGNED_APP_ONLY=1), I did not rerun it.bash run-tests.sh --filter testCIWorkflowContractand--filter testReleaseMetadataContract: 82 of 82 and 35 of 35 passSparkleAppcastDeltaTests.sh,SparkleAppcastSigningTests.sh,freeze-legacy-appcast.py --self-test,mark-appcast-critical.py --self-test,check-known-traps.py --self-testand the full check,test-nightly-security-check.py,packaged-app-smoke.py --self-test,check-source-pins.py --changed-only,check-test-shape.py.sign_updatewith a random throwaway key:sign-appcast.shsigned 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.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 inrelease-package.share byte-identical moves of thebuild-beta.shfunctions. No secret or environment reaches a job that compiles or runs repo Swift code. No baseline grew, no check was loosened, and no test readsSources/. Fixes made in review:packaged-app-smoke.pykept its self-link and version checks for the generated feed; before, it skipped them for any feed onceSUFeedURLmovedSURequireSignedFeedstays off, the redirect chain as observed, and that the freeze must land with the version bump (otherwiseReleaseMetadataContractTestsgo red)🤖 Generated with Claude Code