Releases and updates
How Thaw ships installers vs in-app updates.
Who hosts what
| What | Where | URL pattern |
|---|---|---|
Appcast (appcast.xml) | thaw-app/updates GitHub Pages | https://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 releases | same files, Thaw release URLs (not used by appcast) |
| DMG (human installer) + SBOM + Sigstore bundles + SLSA provenance | thaw-app/Thaw GitHub Releases only | https://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
- App opens
https://thaw-app.github.io/updates/appcast.xml. - Appcast lists the newest build and points at a ZIP (or delta) on
updatesreleases. - Sparkle downloads that file from
thaw-app/updates. - Sparkle verifies the EdDSA signature against
SUPublicEDKey. - 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.
- Build and notarize.
- Generate a CycloneDX SBOM of the resolved SwiftPM dependencies with Syft
(
Thaw_<tag>.cdx.json), and checksum the DMG. - Create Sparkle ZIP (and deltas when prior ZIPs exist).
- Publish ZIP + deltas to
thaw-app/updates(same tag). - Cosign-sign the installer DMG and SBOM; create the
thaw-app/Thawrelease as a draft with DMG + SBOM +*.sigstore.json+*.sha256, and (during cutover) the same Sparkle ZIP + deltas as a mirror. - Push signed
appcast.xmltothaw-app/updatesgh-pages(new enclosure URLs point atupdates, not the Thaw mirror). - Sign build provenance for the DMG and SBOM in the reusable
attest-build-provenance.ymlworkflow (a signing identity separate from the macOS build job); attach*.intoto.jsonlto 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/repoAnything 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.xmlagainst the live feed atthaw-app.github.io/updates, plus the filtered legacy appcast againststonerl.github.io/Thawfor 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.
| Subscriber | Receives |
|---|---|
| Stable | items with no sparkle:channel |
| Beta | untagged items, plus beta |
| Alpha | untagged 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.
- Add a
## [2.1.0]section toCHANGELOG.mdondevelopment. - Run Promote from
developmentwith tag2.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 -vRelated
- Verifying releases: signatures, public key, how to check a build
- Assurance case: update authenticity claims
Architecture
High-level design of the software produced by the Thaw project. This is a map of major components and trust boundaries, not an API reference.
Verifying releases
Thaw ships macOS app builds intended for wide use. Releases are protected by several layers. This page explains what those layers are and how you can check them.