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
4 changes: 4 additions & 0 deletions .github/workflows/ci-guards.yml
Original file line number Diff line number Diff line change
Expand Up @@ -715,6 +715,10 @@ jobs:
if: ${{ matrix.group == 'release-notary' }}
run: bash ./tests/test_nightly_universal_build.sh

- name: Validate nightly Sparkle key selection
if: ${{ matrix.group == 'release-notary' }}
run: bash ./tests/test_nightly_sparkle_key.sh

- name: Validate nightly push throttle
if: ${{ matrix.group == 'release-notary' }}
run: |
Expand Down
28 changes: 18 additions & 10 deletions .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1455,12 +1455,15 @@ jobs:

- name: Derive Sparkle public key from private key
env:
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
# scripts/ci/nightly-sparkle-key.sh picks the key per channel and
# NIGHTLY_SPARKLE_KEY; unset keeps the shared key.
NIGHTLY_SPARKLE_KEY: ${{ vars.NIGHTLY_SPARKLE_KEY }}
CHANNEL: ${{ needs.decide.outputs.channel }}
SHARED_SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
NIGHTLY_SPARKLE_PRIVATE_KEY: ${{ secrets.NIGHTLY_SPARKLE_PRIVATE_KEY }}
run: |
if [ -z "$SPARKLE_PRIVATE_KEY" ]; then
echo "Missing SPARKLE_PRIVATE_KEY secret" >&2
exit 1
fi
set -euo pipefail
SPARKLE_PRIVATE_KEY="$(./scripts/ci/nightly-sparkle-key.sh)"
DERIVED_PUBLIC_KEY=$(swift scripts/derive_sparkle_public_key.swift "$SPARKLE_PRIVATE_KEY")
echo "Derived Sparkle public key: $DERIVED_PUBLIC_KEY"
echo "SPARKLE_PUBLIC_KEY=$DERIVED_PUBLIC_KEY" >> "$GITHUB_ENV"
Expand Down Expand Up @@ -1796,16 +1799,21 @@ jobs:
- name: Generate Sparkle appcasts (nightly)
if: needs.decide.outputs.fast_build != 'true'
env:
SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
NIGHTLY_SPARKLE_KEY: ${{ vars.NIGHTLY_SPARKLE_KEY }}
CHANNEL: ${{ needs.decide.outputs.channel }}
SHARED_SPARKLE_PRIVATE_KEY: ${{ secrets.SPARKLE_PRIVATE_KEY }}
NIGHTLY_SPARKLE_PRIVATE_KEY: ${{ secrets.NIGHTLY_SPARKLE_PRIVATE_KEY }}
# Deltas from the two previous builds of this track, named
# cmux-nightly-macos-<variant>-<new>-<old>.delta so tracks never collide.
SPARKLE_PREVIOUS_ARCHIVES_DIR: previous-nightlies
SPARKLE_MAXIMUM_DELTAS: "2"
run: |
if [ -z "$SPARKLE_PRIVATE_KEY" ]; then
echo "Missing SPARKLE_PRIVATE_KEY secret" >&2
exit 1
fi
set -euo pipefail
SPARKLE_PRIVATE_KEY="$(./scripts/ci/nightly-sparkle-key.sh)"
export SPARKLE_PRIVATE_KEY
# After a key change, deltas from builds that embed the other key can
# never install; skip building them. SPARKLE_PUBLIC_KEY is derived above.
./scripts/ci/drop-previous-nightlies-with-other-sparkle-key.sh previous-nightlies "$SPARKLE_PUBLIC_KEY"
SPARKLE_DELTA_NAME_PREFIX="${CHANNEL_DMG_PREFIX}-${NIGHTLY_VARIANT}-" \
./scripts/sparkle_generate_appcast.sh "$NIGHTLY_DMG_IMMUTABLE" "$CHANNEL_RELEASE_TAG" "$NIGHTLY_APPCAST"
ls -l "$NIGHTLY_DMG_IMMUTABLE" "$NIGHTLY_DMG_RELEASE" "$NIGHTLY_APPCAST" ./*.delta 2>/dev/null || true
Expand Down
2 changes: 2 additions & 0 deletions scripts/ci/detect_ci_change_areas.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ def is_other_workflow_config(path: str) -> bool:
# compile/test lanes. Validate publishing through its guards/release workflows.
CI_PUBLISHING_ONLY = frozenset({
"scripts/ci/download-run-artifact.py",
"scripts/ci/drop-previous-nightlies-with-other-sparkle-key.sh",
"scripts/ci/nightly-sparkle-key.sh",
"scripts/prebuild_sparkle_deltas.sh",
"scripts/sparkle_generate_appcast.sh",
})
Expand Down
34 changes: 34 additions & 0 deletions scripts/ci/drop-previous-nightlies-with-other-sparkle-key.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# Keep only previous nightly DMGs that embed the Sparkle key this build signs with.
#
# drop-previous-nightlies-with-other-sparkle-key.sh <dir-of-previous-dmgs> <public-key>
#
# Sparkle validates a delta update against the installed app's EdDSA key only;
# the Developer ID fallback that lets a full update rotate keys does not apply
# to deltas. After a key change (NIGHTLY_SPARKLE_KEY), a delta built from a
# previous build that embeds the other key can never install: clients download
# it, reject it, and fall back to the full DMG. Removing those DMGs before
# generate_appcast skips building them. Anything unreadable is kept, which only
# costs a delta that might be rejected, never a publish.
set -euo pipefail

dir="${1:?previous DMG directory}"
expected="${2:?Sparkle public key this build signs with}"

shopt -s nullglob
for dmg in "$dir"/*.dmg; do
mount="$(mktemp -d "${RUNNER_TEMP:-/tmp}/sparkle-key-check.XXXXXX")"
embedded=""
if hdiutil attach -nobrowse -readonly -noverify -mountpoint "$mount" "$dmg" >/dev/null 2>&1; then
app="$(find "$mount" -maxdepth 1 -name '*.app' -print -quit)"
if [ -n "$app" ]; then
embedded="$(/usr/libexec/PlistBuddy -c 'Print :SUPublicEDKey' "$app/Contents/Info.plist" 2>/dev/null || true)"
fi
hdiutil detach "$mount" -quiet || hdiutil detach "$mount" -force -quiet || true
fi
rmdir "$mount" 2>/dev/null || true
if [ -n "$embedded" ] && [ "$embedded" != "$expected" ]; then
echo "skipping delta from $(basename "$dmg"): it embeds a different Sparkle key"
rm -f "$dmg"
fi
done
37 changes: 37 additions & 0 deletions scripts/ci/nightly-sparkle-key.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Print the Sparkle EdDSA private key a nightly build embeds and signs with.
#
# NIGHTLY_SPARKLE_KEY=nightly moves the nightly channel onto its own key
# (the NIGHTLY_SPARKLE_PRIVATE_KEY secret), so a machine that signs nightlies
# never holds the key stable releases use. Unset keeps the shared key. The rc
# channel always keeps the shared key.
#
# Installed nightlies accept the switch without a transition build: Sparkle
# allows an update to change its EdDSA key when the app stays code signed with
# the same Apple Developer ID. Never rotate the Developer ID certificate in the
# same build. https://sparkle-project.org/documentation/ ("Rotating signing keys")
#
# Inputs come from the environment: NIGHTLY_SPARKLE_KEY, CHANNEL,
# SHARED_SPARKLE_PRIVATE_KEY, NIGHTLY_SPARKLE_PRIVATE_KEY.
set -euo pipefail

selector="${NIGHTLY_SPARKLE_KEY:-}"
case "$selector" in "" | nightly) ;; *)
echo "NIGHTLY_SPARKLE_KEY must be unset or nightly, got: $selector" >&2
exit 1 ;;
esac

if [[ "$selector" == nightly && "${CHANNEL:-}" == nightly ]]; then
if [[ -z "${NIGHTLY_SPARKLE_PRIVATE_KEY:-}" ]]; then
echo "NIGHTLY_SPARKLE_KEY=nightly needs the NIGHTLY_SPARKLE_PRIVATE_KEY secret" >&2
exit 1
fi
printf '%s' "$NIGHTLY_SPARKLE_PRIVATE_KEY"
exit 0
fi

if [[ -z "${SHARED_SPARKLE_PRIVATE_KEY:-}" ]]; then
echo "Missing SPARKLE_PRIVATE_KEY secret" >&2
exit 1
fi
printf '%s' "$SHARED_SPARKLE_PRIVATE_KEY"
2 changes: 2 additions & 0 deletions scripts/ci/workflow_guard_groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@

"scripts/ci/ios_upload_batch_decision.py": frozenset(("release-ios",)),
"scripts/ci/peer_product_source.py": frozenset(("preflight",)),
"scripts/ci/drop-previous-nightlies-with-other-sparkle-key.sh": frozenset(("release-notary",)),
"scripts/ci/nightly-sparkle-key.sh": frozenset(("release-notary",)),
"scripts/ci/nightly_mini_route.py": frozenset(("preflight",)),
"scripts/ci/persistent_mac_route.py": frozenset(("preflight",)),
"scripts/ci/product_input_identity.py": frozenset(("preflight",)),
Expand Down
50 changes: 50 additions & 0 deletions tests/test_nightly_sparkle_key.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Regression coverage for scripts/ci/nightly-sparkle-key.sh: which Sparkle key
# a nightly build embeds and signs with.
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
PICK="$ROOT_DIR/scripts/ci/nightly-sparkle-key.sh"
WORKFLOW="$ROOT_DIR/.github/workflows/nightly.yml"

expect() {
# expect <want> <selector> <channel> [nightly-key]
local want="$1" got
got="$(NIGHTLY_SPARKLE_KEY="$2" CHANNEL="$3" SHARED_SPARKLE_PRIVATE_KEY=shared \
NIGHTLY_SPARKLE_PRIVATE_KEY="${4-nightly}" "$PICK" 2>/dev/null)" || got="<error>"
if [ "$got" != "$want" ]; then
echo "FAIL: selector='$2' channel=$3 picked '$got', want '$want'"
exit 1
fi
}

expect shared "" nightly
expect nightly nightly nightly
# The rc channel never moves.
expect shared nightly rc
# Selecting the nightly key without the secret fails instead of falling back.
expect "<error>" nightly nightly ""
expect "<error>" typo nightly

if SHARED_SPARKLE_PRIVATE_KEY="" CHANNEL=nightly "$PICK" >/dev/null 2>&1; then
echo "FAIL: a missing shared key must fail"
exit 1
fi

# The embedded public key and the appcast signature must come from the same
# helper, so they can never disagree.
if grep -Eq '^ +SPARKLE_PRIVATE_KEY: \$\{\{ secrets\.' "$WORKFLOW"; then
echo "FAIL: nightly.yml passes SPARKLE_PRIVATE_KEY directly; use scripts/ci/nightly-sparkle-key.sh"
exit 1
fi
if [ "$(grep -c 'SPARKLE_PRIVATE_KEY="$(./scripts/ci/nightly-sparkle-key.sh)"' "$WORKFLOW")" -ne 2 ]; then
echo "FAIL: nightly.yml must derive the embedded key and sign the appcast through the helper"
exit 1
fi

if ! grep -Fq 'drop-previous-nightlies-with-other-sparkle-key.sh previous-nightlies "$SPARKLE_PUBLIC_KEY"' "$WORKFLOW"; then
echo "FAIL: the appcast step must drop previous nightlies signed for another key before building deltas"
exit 1
fi

echo "PASS: nightly Sparkle key selection"
Loading