Releases and updates

How Thaw ships installers vs in-app updates.

Who hosts what

WhatWhereURL pattern
Appcast (appcast.xml)thaw-app/updates GitHub Pageshttps://thaw-app.github.io/updates/appcast.xml
Update payloads (Sparkle ZIP + deltas, all channels)thaw-app/updates GitHub Releases (canonical; appcast enclosures)https://github.com/thaw-app/updates/releases/download/<tag>/…
Update payloads (cutover mirror)Also attached to thaw-app/Thaw releases for ~2–3 releasessame files, Thaw release URLs (not used by appcast)
DMG (human installer) + SBOM + Sigstore bundles + SLSA provenancethaw-app/Thaw GitHub Releases onlyhttps://github.com/thaw-app/Thaw/releases/…

The app polls the appcast (SUFeedURL in Thaw/Resources/Info.plist). Sparkle never downloads the DMG for in-app updates.

Diagram

In-app update path

  1. App opens https://thaw-app.github.io/updates/appcast.xml.
  2. Appcast lists the newest build and points at a ZIP (or delta) on updates releases.
  3. Sparkle downloads that file from thaw-app/updates.
  4. Sparkle verifies the EdDSA signature against SUPublicEDKey.
  5. Sparkle installs the update.

The DMG is not on this path. It is for people who download an installer from GitHub (or similar).

What the release job does

Order matters: update assets are published before the Thaw DMG so a failed updates publish does not leave a public installer without matching Sparkle payloads.

  1. Build and notarize.
  2. Generate a CycloneDX SBOM of the resolved SwiftPM dependencies with Syft (Thaw_<tag>.cdx.json), and checksum the DMG.
  3. Create Sparkle ZIP (and deltas when prior ZIPs exist).
  4. Publish ZIP + deltas to thaw-app/updates (same tag).
  5. Cosign-sign the installer DMG and SBOM; create the thaw-app/Thaw release as a draft with DMG + SBOM + *.sigstore.json + *.sha256, and (during cutover) the same Sparkle ZIP + deltas as a mirror.
  6. Push signed appcast.xml to thaw-app/updates gh-pages (new enclosure URLs point at updates, not the Thaw mirror).
  7. Sign build provenance for the DMG and SBOM in the reusable attest-build-provenance.yml workflow (a signing identity separate from the macOS build job); attach *.intoto.jsonl to the draft, then publish it when Publish release is checked.

Cutover dual-publish

For the first ~2–3 releases after moving Sparkle hosting to thaw-app/updates, ZIP and deltas are uploaded to both repos. The appcast keeps a single enclosure URL per file, pointing at updates. The Thaw copies are a safety net only. Remove the Thaw Sparkle attachments once a couple of updates-hosted releases have shipped cleanly.

The release is drafted in step 5 and published in step 7 so that a failed attestation leaves an unpublished draft rather than a public release with no provenance. The appcast in step 6 goes out first, so an appcast entry’s release link can 404 for the minute or two the attestation jobs take; in-app updates are unaffected, since Sparkle downloads from thaw-app/updates.

Workflow: .github/workflows/release.yml.
Shared Sparkle action: thaw-app/org-ci sparkle-release.

Required release-environment secret on Thaw: UPDATES_GITHUB_TOKEN (contents: write on thaw-app/updates). The updates softprops step must pass it as the action token input, because softprops v3 ignores env: GITHUB_TOKEN.

Dispatching a release

Releases build an existing tag in thaw-app/Thaw, using that tag’s project, deployment target, dependencies, and changelog. There is no separate source repository or private-package token to configure.

The workflow is workflow_dispatch only, and its tag input is free text: Actions choice inputs are a static list in the YAML, so they cannot be filled from the tags that exist. scripts/release.sh supplies that list locally instead. It reads the remote tags, filters them to the shape the workflow accepts, lets you pick one, asks for the other inputs, and dispatches the run.

scripts/release.sh          # override the target with REPO=owner/repo

Anything the script does can be done by hand from the Actions tab or with gh workflow run release.yml -f tag=3.0.0-beta.1 ...; the script only removes the chance of dispatching a tag that does not exist. A 3.0.0-beta.1 tag selects the beta channel automatically.

Xcode selection

Release and DMG builds use setup-xcode with xcode-version: latest on the xcode-27 runner. This selects the newest installed Xcode, including betas; it does not download versions that are absent from the runner. The action is pinned to a commit, but the Xcode version follows runner-image updates. Use an exact xcode-version instead if a release needs a fixed toolchain.

Building a DMG without releasing

.github/workflows/build-dmg.yml builds a signed, notarized DMG as an artifact retained for three days. Select the workflow branch to build its triggering commit, or set ref to another branch, tag, or commit in this repository. Leaving ref empty does not switch to the default branch. The .build PR command uses this behavior to build the PR branch.

Release discussions

Discussion category opens a linked discussion in that category. It defaults to none and only takes effect when Publish release is checked, because GitHub creates the discussion on the draft-to-published transition, which is step 7. Setting it on the draft in step 5 would do nothing.

Dry runs

Check Dry run when dispatching the workflow to build and report without publishing anything. Steps 1–3 run normally; steps 4–7 are skipped, so no GitHub Release is created (not even a draft), nothing is cosign-signed or attested, and no appcast is pushed. For 3.x releases, thaw-app/updates is the only appcast destination; 2.x releases also mirror to the legacy repository.

Signing and attestation are skipped deliberately: cosign keyless signing and GitHub Artifact Attestations write permanent, public Sigstore / attestation records that cannot be retracted, so a rehearsal must not produce them.

The run’s job summary then reports:

  • every asset that would be uploaded, to which repository, with size and SHA-256, and whether the release would be a draft or published;
  • the SBOM component inventory;
  • a unified diff of the generated appcast.xml against the live feed at thaw-app.github.io/updates, plus the filtered legacy appcast against stonerl.github.io/Thaw for 2.x releases only, so you can see exactly what each update push would change.

The DMG checksum, SBOM (+ checksum), and generated appcast are attached to the run as a dry-run-<tag> artifact for local inspection. For 2.x releases, this also includes legacy-appcast.xml, the filtered feed destined for the mirror. The DMG itself is not attached, because it is large and is rebuilt by the real release run.

Dry runs use a separate concurrency group, so they never queue behind or block a real release.

Channels

All three channels share one appcast, served from the feed host named by SUFeedURL. Stable items carry no sparkle:channel; beta and alpha items are tagged with theirs. Tag suffixes map to channels in the release workflow (-beta / -rc → beta, -alpha / -nightly → alpha), and the channel input overrides the inference when a tag needs to go somewhere its suffix does not imply.

Subscribers pick one channel in Settings › About. All three read the same SUFeedURL; the appcast’s sparkle:channel tags do the sorting.

SubscriberReceives
Stableitems with no sparkle:channel
Betauntagged items, plus beta
Alphauntagged items, plus alpha, never beta

Beta is cumulative with stable, and that is not a choice: Sparkle’s allowedChannels only widens what an updater accepts. An item with no sparkle:channel is on the default channel, and per SPUUpdaterDelegate, “the default channel is always included in the allowed set.” No delegate return value keeps stable releases away from a subscriber.

Alpha is only selectable on the macOS the rewrite targets. The threshold is MacOSCompatibilityWarning.firstUnsupportedMajorVersion, shared with the startup warning whose alert tells the user that support for that macOS arrives through this channel, so the release that raises the warning is the release that reveals the channel. On earlier systems alpha is absent from the picker, and a stored alpha selection is not honored, which keeps a user who moves back to a supported macOS from sitting on a feed that will never offer them anything.

Alpha carries the rewritten app built against the next macOS: a different product line, not a riskier build of this one. It still shares the feed, because Sparkle offers the newest item a subscriber is allowed to see, and a 3.x alpha item outranks anything the 2.x line can publish. An alpha subscriber does see the stable items; they simply never win. This holds only while stable stays behind alpha in version order, which is why no further 2.x stable release can be numbered above the alpha line.

Alpha items are tagged sparkle:channel = alpha, so beta subscribers never see them: beta allows beta and the default, not alpha. The two tracks run in parallel rather than one containing the other.

Cross-version safety does not rely on any of that. generate_appcast derives sparkle:minimumSystemVersion from each build’s deployment target, so the 3.x items carry 27.0 and Sparkle skips them on macOS 26, including later, when 3.0 goes final and drops its channel tag to become the default.

Promotion between stable and beta stays cheap, because they share a feed: to move a build from beta to stable, drop its sparkle:channel rather than publishing a second item for the same version. Beta subscribers already have that build and are offered nothing; stable subscribers pick it up. Two items sharing a version and differing only by channel is the case to avoid. For 2.x releases, promotion also reaches the mirrored legacy appcast.

Promoting a release candidate

.github/workflows/promote.yml ships a published candidate as stable without rebuilding it. This only works for a candidate built with its final version: MARKETING_VERSION = "2.1.0", a new build number, tagged 2.1.0-rc.1. The version string is inside the signed app, so a build made as 2.1.0-rc.1 would say so in About forever, and the workflow refuses it. While the build is on the beta channel, appcast preparation shows the tag as its version, so testers still see 2.1.0-rc.1.

  1. Add a ## [2.1.0] section to CHANGELOG.md on development.
  2. Run Promote from development with tag 2.1.0-rc.1 (dry run first).

It removes the item’s channel, renames it 2.1.0 and gives it the stable notes; uploads the same ZIP as Thaw_2.1.0.zip to a 2.1.0 release on thaw-app/updates, so later stable releases can build deltas from it; tags 2.1.0 on the candidate’s commit; and publishes a 2.1.0 release with the candidate’s DMG, SBOM, Sigstore bundles and provenance, which still verify because the bytes are the same. generate_appcast --channel only applies to items it creates, so later releases leave the promoted item alone. Each step checks for its own result first, so a run that fails partway can be rerun with the same tag.

Switching away from alpha does not roll a user back. The alpha app’s version line is ahead of the shipping app’s, so the stable feed offers nothing newer and Sparkle stays put. Returning to the shipping app is a reinstall, which is worth saying wherever alpha is advertised.

Legacy installs

Older builds may still poll https://stonerl.github.io/Thaw/appcast.xml (GitHub Pages from stonerl/Thaw main, path /appcast.xml). Only 2.x release tags publish a filtered copy of appcast.xml to that repo after publishing to thaw-app/updates. The copy retains historical 1.x and 2.x entries with their existing enclosure signatures; 3.x and newer entries are excluded. The canonical appcast is unchanged. 3.x releases never push to stonerl/Thaw and do not require LEGACY_APPCAST_TOKEN. Existing installs must have moved to the thaw-app.github.io/updates SUFeedURL to receive the 3.x release line.

Historical enclosure URLs already in the appcast (for example old stonerl/Thaw release ZIP links) stay as-is so EdDSA signatures remain valid. Only new items point at thaw-app/updates releases.

Appcast preparation

The local prepare-appcasts action owns the appcast rules: reapply missing macOS 26 caps to 2.x entries, show a channel item’s tag as its version (2.1.0-rc.1 for a build made as 2.1.0), restore the notes generate_appcast drops from every earlier archive it re-reads for deltas (from a snapshot of the feed taken before the run), and generate a 1.x/2.x-only legacy feed when releasing a 2.x tag. It uses Python’s standard-library XML parser without external dependencies. Its two output paths feed publishing, dry-run comparisons, and artifact uploads.

checkout-source preserves the workflow branch’s local actions before checking out the release tag, so older tags do not need to contain these helpers.

Run the tests locally (no signing credentials or network access needed):

python3 -B -m unittest discover -s .github/actions/prepare-appcasts/tests -v

On this page