Skip to content

chore: signed appcast, a release script that refuses inconsistency, pinned CI - #15

Merged
LucaGerlich merged 7 commits into
mainfrom
chore/release-hardening
Oct 11, 2026
Merged

LucaGerlich merged 7 commits into
mainfrom
chore/release-hardening

Conversation

@LucaGerlich

Copy link
Copy Markdown
Owner

Release hardening from the post-launch audit, to merge before 1.1.0 is cut.

Signed appcast (M2)

The appcast is served from main. Only the update archive was EdDSA-signed, so anyone with push access — or a stolen token — could change the release notes or put a link in the update dialog. 1.1.0 sets SUVerifyUpdateBeforeExtraction and SURequireSignedFeed (Sparkle 2.9): it accepts only an appcast signed with the release key. generate_appcast signs the feed automatically once a build requires it; the signature is an XML comment, so 1.0.x clients keep working. From now on appcast.xml must never be edited by hand (now in CONTRIBUTING).

Scripts/release.sh

  • Preflight before any build: on main and equal to origin/main; no existing tag, GitHub release or dist/<version>/; the exact Developer ID identity; SUPublicEDKey set (now fatal); Sparkle tools resolved from the pinned 2.9.6 via -clonedSourcePackagesDirPath (the old DerivedData glob ran whichever version it found first).
  • Unit tests run before archiving.
  • Each release lives in dist/<version>/ — the old rm -rf dist also deleted the previous dSYMs and dropped earlier appcast items (1.0.0 was already gone).
  • The appcast is generated from a copy of the published one, so old items keep their URLs; no deltas.
  • Fatal verification: sign_update --verify appcast.xml, and the DMG against its edSignature — generate_appcast only warns and writes an unsigned feed if it cannot sign.
  • Asserts the exported app's team, signs the DMG with the full identity and a stable identifier.

CI

macos-26 + Xcode 26.6 (the release toolchain) instead of macos-latest + newest Xcode; actions/checkout pinned to the v7.0.1 SHA; read-only token, concurrency group, 30 min timeout.

Also

  • Drops the unused user-selected-files entitlement; ignores .claude/settings.local.json.
  • Adds docs/post-launch-audit.md with its resolution status.

Test plan

  • bash -n passes; the guards stop a dirty tree and a non-main branch before any work (run locally).
  • The signature extractor returns the published 1.0.1 signature and nothing for an unknown version.
  • -clonedSourcePackagesDirPath delivers Sparkle 2.9.6's generate_appcast and sign_update.
  • Built app carries both Sparkle keys and no user-selected entitlement; unit tests pass.
  • CI green on the pinned runner and Xcode.
  • Full pipeline: the 1.1.0 release run itself (it ends with the fatal signature checks).

The appcast is served from main, which anyone with push access - or a
stolen token - could rewrite to change the release notes or offer a link
in the update dialog. Only the update archive was EdDSA-signed.

Set SUVerifyUpdateBeforeExtraction and SURequireSignedFeed (Sparkle 2.9):
from 1.1.0 on, the app accepts only an appcast and release notes signed
with the release key, and verifies archives before extracting them.
generate_appcast signs the feed automatically when a build requires it;
the signature is an XML comment, so 1.0.x clients still read the feed.
CONTRIBUTING now says appcast.xml must never be edited by hand.
- Preflight before any build: on main and equal to origin/main, no
  existing tag or GitHub release, no earlier output for this version, the
  exact Developer ID identity present, SUPublicEDKey set (now fatal), and
  Sparkle's tools resolved from the version Package.resolved pins instead
  of whatever generate_appcast DerivedData happened to hold.
- Run the unit tests before archiving.
- Keep each release in dist/<version>/ instead of deleting dist/ - which
  also deleted the previous release's dSYMs.
- Build the appcast from a copy of the published one, so earlier releases
  stay in the feed (1.0.0 had already been dropped), without deltas.
- Fatal checks that the appcast is validly signed and that the DMG matches
  its signature in it; generate_appcast only warns and writes an unsigned
  feed when it cannot sign.
- Assert the exported app's team, sign the DMG with the full identity and
  a stable identifier, and copy the appcast into the repo itself.
- macos-26 and Xcode 26.6, the toolchain releases are built with,
  instead of macos-latest and whichever Xcode sorts last (a beta, soon).
- actions/checkout pinned to the v7.0.1 commit SHA.
- Read-only token, a concurrency group and a 30 minute timeout.
ENABLE_USER_SELECTED_FILES produced a user-selected-files entitlement
that does nothing without the sandbox.
Findings from 2026-10-11, with what 1.1.0 fixed ticked, and what is
partly done, needs hardware, needs Luca, or is deferred on purpose.
The update dialog showed no notes, so users updating to a release that
changes behaviour saw nothing about it. release.sh now embeds
Scripts/release-notes/<version>.md into the appcast, where the feed
signature covers it, and adds the notes for 1.1.0.

Also run the unit tests before creating dist/<version>/, so a failing
test leaves nothing behind that would block the next run, and make the
signature extractor print nothing rather than "None" when an item has
no signature.
@LucaGerlich
LucaGerlich merged commit 4035539 into main Oct 11, 2026
1 check passed
@LucaGerlich
LucaGerlich deleted the chore/release-hardening branch October 11, 2026 22:31
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.

1 participant