Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 27 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,11 @@ jobs:
id: signing
env:
CERT: ${{ secrets.DEVELOPER_ID_CERT_P12_BASE64 }}
SPARKLE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
run: |
if [ -n "$CERT" ] && [ -n "${TEAM_ID}" ]; then
# Require the full set (cert + team + Sparkle key) — otherwise the appcast can't be
# signed, so drop cleanly to the unsigned build-only path instead of failing late.
if [ -n "$CERT" ] && [ -n "${TEAM_ID}" ] && [ -n "$SPARKLE_KEY" ]; then
echo "enabled=true" >> "$GITHUB_OUTPUT"
else
echo "enabled=false" >> "$GITHUB_OUTPUT"
Expand Down Expand Up @@ -78,8 +81,29 @@ jobs:
env:
SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
run: |
printf '%s' "$SPARKLE_ED_PRIVATE_KEY" | base64 --decode > "$RUNNER_TEMP/sparkle_ed_private_key"
echo "SPARKLE_ED_KEY_FILE=$RUNNER_TEMP/sparkle_ed_private_key" >> "$GITHUB_ENV"
keyfile="$RUNNER_TEMP/sparkle_ed_private_key"
# generate_appcast --ed-key-file wants the base64 private-key STRING verbatim (exactly
# what `generate_keys -x` writes: one line of base64). Accept the secret whether it was
# stored as that string OR base64-encoded once more: a raw Sparkle key base64-decodes to
# ~32 binary bytes, whereas a double-encoded secret decodes back to a printable base64
# line. Pick whichever form yields a valid key string, so the release can't fail (or,
# worse, silently emit an unsigned appcast) on a secret-encoding mismatch.
dec="$RUNNER_TEMP/sparkle_key_decoded"
printf '%s' "$SPARKLE_ED_PRIVATE_KEY" | base64 --decode > "$dec" 2>/dev/null || true
if LC_ALL=C grep -aEq '^[A-Za-z0-9+/]{40,}={0,2}$' "$dec"; then
cp "$dec" "$keyfile" # secret was double-base64-encoded
else
printf '%s' "$SPARKLE_ED_PRIVATE_KEY" > "$keyfile" # secret stored verbatim
fi
rm -f "$dec"
echo "SPARKLE_ED_KEY_FILE=$keyfile" >> "$GITHUB_ENV"

- name: Warm the SwiftPM cache (resolves Sparkle's generate_appcast into .build/artifacts)
if: steps.signing.outputs.enabled == 'true'
# Belt-and-suspenders: the archive step also resolves Sparkle into DerivedData (which
# appcast.sh searches), so a transient resolve hiccup must not block a shippable release.
continue-on-error: true
run: swift package resolve

- name: Build, sign, notarize, staple, package, appcast
if: steps.signing.outputs.enabled == 'true'
Expand Down
6 changes: 4 additions & 2 deletions App/Capsule.entitlements
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,10 @@
Sparkle: the UNSANDBOXED updater needs NO extra entitlements. Sparkle's XPC services
(Installer/Downloader) and the com.apple.security.temporary-exception.* keys exist only
for the SANDBOXED case. Because Capsule is unsandboxed, the in-process updater is used and
this file stays empty. The Sparkle framework is built from source via SwiftPM and signed
with the app under the same Developer ID, so no library-validation exception is required.
this file stays empty. Sparkle ships as a prebuilt, Sparkle-signed binary xcframework (a
SwiftPM binaryTarget); `xcodebuild -exportArchive` re-signs it — and its nested
Autoupdate / Updater.app / XPC services — inside-out with Capsule's own Developer ID, so
library validation passes and no com.apple.security.cs.disable-library-validation is required.
-->
</dict>
</plist>
16 changes: 14 additions & 2 deletions Scripts/release/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ Individual steps (each accepts `--dry-run`):
| 2 | `export.sh` | `make export` | `xcodebuild -exportArchive` (Developer ID) → signed `dist/Capsule.app` (inside-out, incl. Sparkle; Hardened Runtime) |
| 3 | `notarize.sh` | `make notarize` | zip + `notarytool submit --wait` + `stapler staple` |
| 4 | `package.sh` | `make package` | `dist/Capsule-<v>.zip` (Sparkle) + `dist/Capsule-<v>.dmg` |
| 5 | `appcast.sh` | `make appcast` | Sparkle `generate_appcast` signs the zip + writes `appcast.xml` (with `RELEASE_DOWNLOAD_URL_PREFIX`) |
| 5 | `appcast.sh` | `make appcast` | Sparkle `generate_appcast` signs the zip (in an isolated dir — the `.dmg` is kept out so Sparkle doesn't reject the same-version pair) + writes `appcast.xml` (with `RELEASE_DOWNLOAD_URL_PREFIX`), then **fails if any enclosure is unsigned** |

## Environment (local runs)

Expand All @@ -60,8 +60,9 @@ Individual steps (each accepts `--dry-run`):
| `TEAM_ID` | signed runs | Apple Developer Team ID |
| `NOTARY_PROFILE` | signed runs | `notarytool` keychain profile name |
| `RELEASE_DOWNLOAD_URL_PREFIX` | CI-set | prepended to the appcast's enclosure filenames — the tag's release-download URL. Empty → filenames stay relative to the appcast. |
| `SPARKLE_BIN` | optional | dir containing `generate_appcast` / `generate_keys` |
| `SPARKLE_BIN` | optional | dir containing `generate_appcast` / `generate_keys`. If unset, `appcast.sh` searches `.build/artifacts`, `DerivedData/SourcePackages/artifacts`, and the global Xcode DerivedData (where `xcodebuild archive` resolves Sparkle), then `PATH`. |
| `SPARKLE_ED_KEY_FILE` | optional | private-key file; else the login keychain |
| `SPARKLE_ACCOUNT` | optional | keychain account for the private key when `SPARKLE_ED_KEY_FILE` is unset (this repo's key was generated under `capsule-native`; without it a local run signs with the wrong/default account → unsigned enclosures) |
| `DIST_DIR` | optional | output dir (default `dist/`) |

## Cutting a release
Expand All @@ -77,6 +78,17 @@ Individual steps (each accepts `--dry-run`):

- **Version comes from the app, not the tag.** Artifact names and the Sparkle appcast use
`CFBundleShortVersionString` from `App/Info.plist`; the tag guard just enforces they agree.
- **Unsigned appcasts can't ship.** If the signing key's public half doesn't match the app's
`SUPublicEDKey`, `generate_appcast` only *warns* and writes an enclosure with no
`sparkle:edSignature` (every client then rejects the update). `appcast.sh` now **fails the
build** unless every enclosure is signed, so this can't slip out silently.
- **Sparkle key secret is encoding-agnostic.** `release.yml` accepts `SPARKLE_ED_PRIVATE_KEY`
whether it was stored as Sparkle's raw base64 key string or base64-encoded once more, and
writes the correct `--ed-key-file` either way.
- **`generate_appcast` discovery on CI.** The clean runner has no `.build/`; the tool is resolved
into DerivedData by the `xcodebuild archive` step (and a `swift package resolve` step warms
`.build/artifacts`), and `appcast.sh` searches both — so the appcast step no longer dies for
want of the binary.
- **Notary password on argv.** `notarize.sh` / `release.yml` pass the app-specific password to
`notarytool store-credentials --password` (the API has no stdin form). It's masked and never
echoed; on the ephemeral, single-tenant `macos-26` runner nothing else can read the process
Expand Down
70 changes: 62 additions & 8 deletions Scripts/release/appcast.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,32 @@
# SUFeedURL (App/Info.plist) at wherever you host the resulting appcast.xml.
#
# Optional env: SPARKLE_BIN (dir containing generate_appcast), SPARKLE_ED_KEY_FILE (a private
# key file, if you don't use the keychain).
# key file, if you don't use the keychain), SPARKLE_ACCOUNT (keychain account for the private
# key when SPARKLE_ED_KEY_FILE is unset; defaults to the account the key was generated under).

source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
parse_common_flags "$@"

# Locate generate_appcast: explicit SPARKLE_BIN, then the resolved SwiftPM artifact, then PATH.
# Locate generate_appcast. Sparkle's tools ship inside its SwiftPM binary artifact; where that
# lands depends on how the package was resolved:
# • `swift build`/`swift package resolve` → $REPO_ROOT/.build/artifacts
# • `xcodebuild -derivedDataPath DerivedData` → $REPO_ROOT/DerivedData/SourcePackages/artifacts
# • `xcodebuild archive` (default derived data) → ~/Library/Developer/Xcode/DerivedData/*/SourcePackages/artifacts
# The release pipeline archives with xcodebuild BEFORE this step, so on a clean CI runner the
# tool exists only under the global DerivedData — search all three (plus $SPARKLE_BIN / PATH).
find_generate_appcast() {
if [ -n "${SPARKLE_BIN:-}" ] && [ -x "$SPARKLE_BIN/generate_appcast" ]; then
echo "$SPARKLE_BIN/generate_appcast"; return 0
fi
local hit
hit="$(find "$REPO_ROOT/.build/artifacts" -name generate_appcast -type f 2>/dev/null | head -1 || true)"
if [ -n "$hit" ]; then echo "$hit"; return 0; fi
local root hit
for root in \
"$REPO_ROOT/.build/artifacts" \
"$REPO_ROOT/DerivedData/SourcePackages/artifacts" \
"$HOME/Library/Developer/Xcode/DerivedData"; do
[ -d "$root" ] || continue
hit="$(find "$root" -name generate_appcast -type f 2>/dev/null | head -1 || true)"
if [ -n "$hit" ]; then echo "$hit"; return 0; fi
done
if command -v generate_appcast >/dev/null 2>&1; then command -v generate_appcast; return 0; fi
return 1
}
Expand All @@ -47,12 +60,53 @@ if [ -n "${RELEASE_DOWNLOAD_URL_PREFIX:-}" ]; then
log "Enclosure URL prefix: $RELEASE_DOWNLOAD_URL_PREFIX"
fi

# generate_appcast scans a WHOLE directory and rejects two archives that carry the same bundle
# version — and package.sh emits both Capsule-<v>.zip AND Capsule-<v>.dmg into $DIST_DIR. Run
# generate_appcast over a dedicated dir holding ONLY the Sparkle update zip(s), then move the
# result back. (The .dmg is a human download only; Sparkle never sees it.) Note: this dir is
# reset each run and holds only the current zip, so appcasts are single-item with no deltas —
# fine for full-zip updates; wiring delta/version history would mean retaining prior zips here.
APPCAST_SRC="$DIST_DIR/appcast-src"
run rm -rf "$APPCAST_SRC"
run mkdir -p "$APPCAST_SRC"
if [ "$DRY_RUN" != "1" ]; then
shopt -s nullglob
zips=("$DIST_DIR"/Capsule-*.zip)
shopt -u nullglob
[ ${#zips[@]} -gt 0 ] || die "no Capsule-*.zip in $DIST_DIR — run package.sh first"
for z in "${zips[@]}"; do run cp "$z" "$APPCAST_SRC/"; done
fi

log "Signing artifacts + generating appcast with: $GEN"
if [ -n "${SPARKLE_ED_KEY_FILE:-}" ]; then
run "$GEN" --ed-key-file "$SPARKLE_ED_KEY_FILE" ${PREFIX_ARGS[@]+"${PREFIX_ARGS[@]}"} "$DIST_DIR"
run "$GEN" --ed-key-file "$SPARKLE_ED_KEY_FILE" ${PREFIX_ARGS[@]+"${PREFIX_ARGS[@]}"} "$APPCAST_SRC"
else
# No key file → generate_appcast reads the private key from the login keychain.
run "$GEN" ${PREFIX_ARGS[@]+"${PREFIX_ARGS[@]}"} "$DIST_DIR"
# No key file → generate_appcast reads the private key from the login keychain. Sparkle looks
# up the DEFAULT "ed25519" account unless told otherwise; this key was generated under a named
# account (see App/Info.plist), so pass it through or the run silently emits UNSIGNED enclosures.
ACCOUNT_ARGS=()
[ -n "${SPARKLE_ACCOUNT:-}" ] && ACCOUNT_ARGS=(--account "$SPARKLE_ACCOUNT")
run "$GEN" ${ACCOUNT_ARGS[@]+"${ACCOUNT_ARGS[@]}"} ${PREFIX_ARGS[@]+"${PREFIX_ARGS[@]}"} "$APPCAST_SRC"
fi
[ "$DRY_RUN" = "1" ] || [ -f "$APPCAST_SRC/appcast.xml" ] || die "generate_appcast wrote no appcast.xml — nothing was signed."
run mv "$APPCAST_SRC/appcast.xml" "$DIST_DIR/appcast.xml"

# Guard the silent-unsigned-update failure mode: if the signing key's public half does not match
# the app's embedded SUPublicEDKey, generate_appcast only WARNS, exits 0, and writes enclosures
# with NO sparkle:edSignature — which every client rejects. Refuse to publish such an appcast.
if [ "$DRY_RUN" != "1" ]; then
APPCAST="$DIST_DIR/appcast.xml"
[ -f "$APPCAST" ] || die "generate_appcast produced no appcast.xml"
# `|| true`: zero matches makes grep exit 1, which under `set -euo pipefail` would abort the
# script before the checks below — swallow it so the guard (not set -e) reports the problem.
n_enc="$( { grep -o '<enclosure' "$APPCAST" || true; } | wc -l | tr -d ' ')"
n_sig="$( { grep -o 'sparkle:edSignature' "$APPCAST" || true; } | wc -l | tr -d ' ')"
[ "${n_enc:-0}" -ge 1 ] || die "appcast.xml has no <enclosure> — nothing to publish."
if [ "${n_sig:-0}" -lt "${n_enc:-0}" ]; then
pub="$(/usr/libexec/PlistBuddy -c 'Print :SUPublicEDKey' "$REPO_ROOT/App/Info.plist" 2>/dev/null || echo '?')"
die "appcast.xml has $n_enc enclosure(s) but only $n_sig EdDSA signature(s) — refusing to publish an UNSIGNED update (Sparkle clients reject it). The signing key's public half must equal App/Info.plist SUPublicEDKey ($pub)."
fi
log "Verified $n_sig/$n_enc enclosure(s) carry an EdDSA signature."
fi

log "appcast written → $DIST_DIR/appcast.xml"
Expand Down
44 changes: 40 additions & 4 deletions Scripts/release/export.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
# Runtime and a secure timestamp, which is exactly what notarization requires. Doing it this
# way (instead of hand-rolled `codesign --deep`) is Apple's recommended path.
#
# Required env (real run): DEVELOPER_ID_APP or TEAM_ID.
# Required env (real run): TEAM_ID (the export selects the team's "Developer ID Application"
# certificate; TEAM_ID also scopes notarization).

source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
parse_common_flags "$@"
Expand All @@ -31,6 +32,9 @@ if [ "$DRY_RUN" != "1" ]; then
<key>method</key> <string>developer-id</string>
<key>teamID</key> <string>${TEAM_ID}</string>
<key>signingStyle</key> <string>manual</string>
<!-- Pin identity selection so a second (e.g. renewed/expired) Developer ID Application cert in
the keychain can't be chosen instead of the current one. -->
<key>signingCertificate</key> <string>Developer ID Application</string>
<key>destination</key> <string>export</string>
<!-- Sparkle is delivered via its own appcast, not the App Store, so no manifest is needed. -->
</dict>
Expand All @@ -50,10 +54,42 @@ run xcodebuild -exportArchive \
run rm -rf "$DIST_DIR/Capsule.app"
run cp -R "$EXPORT_DIR/Capsule.app" "$DIST_DIR/Capsule.app"

# assert_signed <path> <label> — fail unless <path> is signed with this team's identity AND
# carries the Hardened Runtime flag. `codesign --verify --deep` alone can't catch these
# notarization-fatal cases (a nested helper still on Sparkle's team, or a missing runtime flag),
# so check them explicitly on the app and every nested Sparkle Mach-O.
assert_signed() {
local path="$1" label="$2" out team
[ -e "$path" ] || { warn " (skip $label — not present)"; return 0; }
out="$(codesign -dvvv "$path" 2>&1 || true)"
# Parse the variable directly (no pipes): a `printf | grep -q` here can SIGPIPE printf and,
# under `set -euo pipefail`, look like a failure even when the flag is present.
if [[ "$out" =~ TeamIdentifier=([A-Za-z0-9]+) ]]; then team="${BASH_REMATCH[1]}"; else team=""; fi
[ "$team" = "$TEAM_ID" ] || die "signature: $label has TeamIdentifier='$team', expected '$TEAM_ID'"
# Match the `runtime` token inside codesign's comma-joined flags list, e.g.
# flags=0x10000(runtime) or flags=0x12000(library-validation,runtime) — not just a lone
# "(runtime)". (The SDK line is capital-R "Runtime Version="; only the flags use lowercase.)
local flags=""
[[ "$out" =~ flags=0x[0-9a-fA-F]+\(([^\)]*)\) ]] && flags="${BASH_REMATCH[1]}"
case ",$flags," in
*,runtime,*) : ;;
*) die "signature: $label is missing the Hardened Runtime flag (flags='$flags')" ;;
esac
log " ✓ $label (team $team, hardened runtime)"
}

if [ "$DRY_RUN" != "1" ]; then
log "Verifying signature + hardened runtime"
run codesign --verify --deep --strict --verbose=2 "$DIST_DIR/Capsule.app"
run codesign --display --verbose=4 "$DIST_DIR/Capsule.app" 2>&1 | grep -i 'flags\|Authority' || true
APP="$DIST_DIR/Capsule.app"
log "Verifying Developer ID + Hardened Runtime (app + nested Sparkle code)"
run codesign --verify --strict --verbose=2 "$APP"
assert_signed "$APP" "Capsule.app"
FW="$APP/Contents/Frameworks/Sparkle.framework"
if [ -d "$FW" ]; then
assert_signed "$FW" "Sparkle.framework"
while IFS= read -r nested; do
assert_signed "$nested" "${nested#"$APP"/Contents/Frameworks/}"
done < <(find "$FW" \( -name Autoupdate -o -name 'Updater.app' -o -name '*.xpc' \) 2>/dev/null)
fi
fi

log "Export complete → $DIST_DIR/Capsule.app"
2 changes: 1 addition & 1 deletion Scripts/release/release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
# TEAM_ID Apple Developer Team ID
# NOTARY_PROFILE keychain profile for notarytool (xcrun notarytool store-credentials)
# Optional:
# DEVELOPER_ID_APP, SPARKLE_BIN, SPARKLE_ED_KEY_FILE, DIST_DIR
# SPARKLE_BIN, SPARKLE_ED_KEY_FILE, SPARKLE_ACCOUNT, DIST_DIR

source "$(dirname "${BASH_SOURCE[0]}")/lib.sh"
parse_common_flags "$@"
Expand Down
Loading