diff --git a/.github/scripts/macos_auto_update_smoke.sh b/.github/scripts/macos_auto_update_smoke.sh new file mode 100644 index 0000000000..3824f009b6 --- /dev/null +++ b/.github/scripts/macos_auto_update_smoke.sh @@ -0,0 +1,292 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly APP_PATH="/Applications/Lantern.app" +readonly APP_EXECUTABLE="$APP_PATH/Contents/MacOS/Lantern" +readonly DATA_PATH="/Users/Shared/Lantern" +readonly HANDOFF_PATH="$DATA_PATH/E2E/auto-update-handoff.json" +readonly DEFAULTS_DOMAIN="org.getlantern.lantern" +readonly ROBOT_SOURCE=".github/scripts/macos_sparkle_handoff.applescript" +readonly ROBOT_SCRIPT="${RUNNER_TEMP:?RUNNER_TEMP is required}/macos-sparkle-handoff.scpt" +readonly FIXTURE_DMG="${FIXTURE_DMG:?FIXTURE_DMG is required}" +readonly TARGET_JSON="${TARGET_JSON:?TARGET_JSON is required}" +readonly APPCAST_XML="${APPCAST_XML:?APPCAST_XML is required}" +readonly ARTIFACT_DIR="${ARTIFACT_DIR:-smoke-artifacts/macos-auto-update}" +readonly UI_TIMEOUT_SECONDS="${UI_TIMEOUT_SECONDS:-120}" +readonly UPDATE_TIMEOUT_SECONDS="${UPDATE_TIMEOUT_SECONDS:-600}" + +[[ "$UI_TIMEOUT_SECONDS" =~ ^[1-9][0-9]*$ ]] || { + printf 'UI_TIMEOUT_SECONDS must be a positive integer.\n' >&2 + exit 2 +} +[[ "$UPDATE_TIMEOUT_SECONDS" =~ ^[1-9][0-9]*$ ]] || { + printf 'UPDATE_TIMEOUT_SECONDS must be a positive integer.\n' >&2 + exit 2 +} + +MOUNT_PATH="" +ORIGINAL_PID="" +RELAUNCHED_PID="" +RESULT="failure" + +log_e2e() { + printf '[E2E] %s\n' "$*" >&2 +} + +guard_ci_paths() { + [[ "${CI:-}" == "true" && "${GITHUB_ACTIONS:-}" == "true" ]] || { + printf 'This smoke only runs in GitHub Actions CI.\n' >&2 + exit 2 + } + [[ "${RUNNER_ENVIRONMENT:-}" == "self-hosted" ]] || { + printf 'This smoke requires the dedicated self-hosted macOS runner.\n' >&2 + exit 2 + } + [[ "${LANTERN_AUTO_UPDATE_SMOKE:-}" == "true" ]] || { + printf 'The explicit auto-update cleanup guard is missing.\n' >&2 + exit 2 + } + [[ "$APP_PATH" == "/Applications/Lantern.app" && "$DATA_PATH" == "/Users/Shared/Lantern" ]] || { + printf 'Refusing to use unexpected Lantern paths.\n' >&2 + exit 2 + } +} + +lantern_pids() { + pgrep -f "^${APP_EXECUTABLE}([[:space:]]|$)" 2>/dev/null || true +} + +quit_lantern() { + osascript -e 'tell application id "org.getlantern.lantern" to quit' >/dev/null 2>&1 || true + local pid + while IFS= read -r pid; do + [[ -n "$pid" ]] && kill -TERM "$pid" 2>/dev/null || true + done < <(lantern_pids) +} + +wait_for_exit() { + local pid="$1" + local deadline=$((SECONDS + $2)) + while kill -0 "$pid" 2>/dev/null; do + ((SECONDS < deadline)) || return 1 + sleep 1 + done +} + +wait_for_new_pid() { + local excluded_pid="$1" + local deadline=$((SECONDS + $2)) + while ((SECONDS < deadline)); do + local pid + while IFS= read -r pid; do + if [[ -n "$pid" && "$pid" != "$excluded_pid" ]] && kill -0 "$pid" 2>/dev/null; then + printf '%s\n' "$pid" + return 0 + fi + done < <(lantern_pids) + sleep 1 + done + return 1 +} + +capture_screenshot() { + local name="$1" + screencapture -x "$ARTIFACT_DIR/$name.png" \ + 2>"$ARTIFACT_DIR/$name-screenshot-error.txt" || true +} + +capture_processes() { + local name="$1" + { date -u; ps -axo pid=,ppid=,etime=,state=,command=; } \ + >"$ARTIFACT_DIR/processes-$name.txt" 2>&1 || true +} + +bundle_value() { + /usr/libexec/PlistBuddy -c "Print :$1" "$APP_PATH/Contents/Info.plist" +} + +capture_versions() { + local name="$1" + { + printf 'CFBundleVersion=%s\n' "$(bundle_value CFBundleVersion)" + printf 'CFBundleShortVersionString=%s\n' "$(bundle_value CFBundleShortVersionString)" + codesign -dvvv "$APP_PATH" + } >"$ARTIFACT_DIR/versions-$name.txt" 2>&1 || true +} + +verify_bundle() { + local name="$1" + local expected_build="$2" + local expected_display="$3" + [[ "$(bundle_value CFBundleVersion)" == "$expected_build" ]] || { + printf '%s bundle build did not match %s.\n' "$name" "$expected_build" >&2 + return 1 + } + [[ "$(bundle_value CFBundleShortVersionString)" == "$expected_display" ]] || { + printf '%s display version did not match %s.\n' "$name" "$expected_display" >&2 + return 1 + } + { + codesign --verify --deep --strict --verbose=4 "$APP_PATH" + spctl --assess --type execute --verbose=4 "$APP_PATH" + } >"$ARTIFACT_DIR/signature-$name.txt" 2>&1 +} + +detach_dmg() { + if [[ -n "$MOUNT_PATH" && -d "$MOUNT_PATH" ]]; then + hdiutil detach "$MOUNT_PATH" -quiet >/dev/null 2>&1 || true + rmdir "$MOUNT_PATH" 2>/dev/null || true + MOUNT_PATH="" + fi +} + +cleanup() { + guard_ci_paths + quit_lantern + local tracked_pid + for tracked_pid in "$ORIGINAL_PID" "$RELAUNCHED_PID"; do + if [[ -n "$tracked_pid" ]] && kill -0 "$tracked_pid" 2>/dev/null; then + kill -TERM "$tracked_pid" 2>/dev/null || true + wait_for_exit "$tracked_pid" 10 || kill -KILL "$tracked_pid" 2>/dev/null || true + fi + done + local pid + while IFS= read -r pid; do + if [[ -n "$pid" ]] && ! wait_for_exit "$pid" 10; then + kill -KILL "$pid" 2>/dev/null || true + fi + done < <(lantern_pids) + rm -rf -- "$APP_PATH" + rm -rf -- "$DATA_PATH" + defaults delete "$DEFAULTS_DOMAIN" >/dev/null 2>&1 || true +} + +capture_diagnostics() { + mkdir -p "$ARTIFACT_DIR" + { + printf 'result=%s\noriginal_pid=%s\nrelaunched_pid=%s\n' \ + "$RESULT" "$ORIGINAL_PID" "$RELAUNCHED_PID" + } >"$ARTIFACT_DIR/result.txt" + capture_processes final + [[ -d "$APP_PATH" ]] && capture_versions final + if [[ -d "$DATA_PATH/Logs" ]]; then + mkdir -p "$ARTIFACT_DIR/lantern-logs" + cp -R "$DATA_PATH/Logs/." "$ARTIFACT_DIR/lantern-logs/" 2>/dev/null || true + fi + if [[ -d "$HOME/Library/Logs/Sparkle" ]]; then + mkdir -p "$ARTIFACT_DIR/sparkle-logs" + cp -R "$HOME/Library/Logs/Sparkle/." "$ARTIFACT_DIR/sparkle-logs/" 2>/dev/null || true + fi + log show --last 90m --style syslog \ + --predicate 'process == "Lantern" OR process == "Updater" OR process == "Installer" OR process == "Downloader" OR eventMessage CONTAINS[c] "Sparkle" OR subsystem == "org.getlantern.lantern"' \ + >"$ARTIFACT_DIR/unified-lantern-sparkle.log" 2>&1 || true +} + +on_exit() { + local status=$? + trap - EXIT INT TERM + set +e + capture_diagnostics + capture_screenshot final + detach_dmg + cleanup + exit "$status" +} +trap on_exit EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +install_fixture() { + MOUNT_PATH="$(mktemp -d)" + hdiutil attach "$FIXTURE_DMG" -nobrowse -readonly -mountpoint "$MOUNT_PATH" >/dev/null + local source_app + source_app="$(find "$MOUNT_PATH" -maxdepth 3 -type d -name Lantern.app -print -quit)" + [[ -n "$source_app" ]] || return 1 + ditto "$source_app" "$APP_PATH" + detach_dmg +} + +guard_ci_paths +mkdir -p "$ARTIFACT_DIR" +cp "$APPCAST_XML" "$ARTIFACT_DIR/appcast.xml" +cp "$TARGET_JSON" "$ARTIFACT_DIR/resolved-target.json" +osacompile -o "$ROBOT_SCRIPT" "$ROBOT_SOURCE" + +TARGET_BUILD="$(jq -er '.target_build | tostring' "$TARGET_JSON")" +FIXTURE_BUILD="$(jq -er '.fixture_build | tostring' "$TARGET_JSON")" +DISPLAY_VERSION="$(jq -er .display_version "$TARGET_JSON")" +readonly TARGET_BUILD FIXTURE_BUILD DISPLAY_VERSION + +cleanup +mkdir -p "$DATA_PATH/E2E" +{ + xcrun stapler validate "$FIXTURE_DMG" + spctl --assess --type open --context context:primary-signature --verbose=4 "$FIXTURE_DMG" +} >"$ARTIFACT_DIR/fixture-notarization.txt" 2>&1 +install_fixture +verify_bundle fixture "$FIXTURE_BUILD" "$DISPLAY_VERSION" +capture_versions fixture + +log_e2e "running the Flutter auto-update robot against the installed fixture" +set +e +flutter drive \ + --profile \ + --use-application-binary="$APP_PATH" \ + --keep-app-running \ + --driver=test_driver/integration_test.dart \ + --target=integration_test/auto_update/desktop_auto_update_smoke_test.dart \ + --device-id=macos \ + >"$ARTIFACT_DIR/flutter-drive.log" 2>&1 +drive_status=$? +set -e +cat "$ARTIFACT_DIR/flutter-drive.log" +[[ "$drive_status" -eq 0 ]] || exit "$drive_status" + +[[ -f "$HANDOFF_PATH" ]] || { + printf 'Flutter test completed without creating its native handoff.\n' >&2 + exit 1 +} +cp "$HANDOFF_PATH" "$ARTIFACT_DIR/auto-update-handoff.json" +if [[ -f "$DATA_PATH/Logs/screenshots/auto-update-before.png" ]]; then + cp "$DATA_PATH/Logs/screenshots/auto-update-before.png" "$ARTIFACT_DIR/before.png" +fi +ORIGINAL_PID="$(jq -er '.pid | tostring' "$HANDOFF_PATH")" +[[ "$(jq -er .build_number "$HANDOFF_PATH")" == "$FIXTURE_BUILD" ]] || { + printf 'Flutter test ran against the wrong fixture build.\n' >&2 + exit 1 +} +[[ "$(jq -er .display_version "$HANDOFF_PATH")" == "$DISPLAY_VERSION" ]] || { + printf 'Flutter test ran against the wrong fixture display version.\n' >&2 + exit 1 +} +kill -0 "$ORIGINAL_PID" +original_command="$(ps -p "$ORIGINAL_PID" -o command=)" +[[ "$original_command" == "$APP_EXECUTABLE"* ]] || { + printf 'Flutter handoff PID %s is not the installed Lantern app: %s\n' \ + "$ORIGINAL_PID" "$original_command" >&2 + exit 1 +} +capture_processes prompt + +log_e2e "waiting for the native Sparkle prompt from process $ORIGINAL_PID" +osascript "$ROBOT_SCRIPT" wait-prompt "$ORIGINAL_PID" "$UI_TIMEOUT_SECONDS" \ + | tee "$ARTIFACT_DIR/sparkle-prompt.txt" +capture_screenshot prompt +osascript "$ROBOT_SCRIPT" install-until-exit "$ORIGINAL_PID" "$UPDATE_TIMEOUT_SECONDS" \ + 2>&1 | tee "$ARTIFACT_DIR/sparkle-install.txt" + +log_e2e "waiting for Sparkle to replace and relaunch Lantern" +if kill -0 "$ORIGINAL_PID" 2>/dev/null; then + printf 'Original Lantern process is still running after Sparkle completed.\n' >&2 + exit 1 +fi +RELAUNCHED_PID="$(wait_for_new_pid "$ORIGINAL_PID" "$UI_TIMEOUT_SECONDS")" +osascript "$ROBOT_SCRIPT" wait-main "$RELAUNCHED_PID" "$UI_TIMEOUT_SECONDS" \ + | tee "$ARTIFACT_DIR/main-window-after.txt" +verify_bundle updated "$TARGET_BUILD" "$DISPLAY_VERSION" +capture_versions updated +capture_processes after +capture_screenshot after + +RESULT="success" +log_e2e "auto-update smoke passed: build $FIXTURE_BUILD -> $TARGET_BUILD" diff --git a/.github/scripts/macos_sparkle_handoff.applescript b/.github/scripts/macos_sparkle_handoff.applescript new file mode 100644 index 0000000000..9bd89d51b8 --- /dev/null +++ b/.github/scripts/macos_sparkle_handoff.applescript @@ -0,0 +1,148 @@ +-- The Flutter integration test owns Lantern's UI. This helper only crosses +-- the native Sparkle boundary and confirms a window exists after relaunch. + +property installButtonNames : {"Install Update", "Install and Relaunch"} +property pollInterval : 0.5 + +on findButton(elementRef) + tell application "System Events" + try + if role of elementRef is "AXButton" then + set buttonName to name of elementRef as text + if installButtonNames contains buttonName and enabled of elementRef then return elementRef + end if + end try + try + set children to UI elements of elementRef + on error + set children to {} + end try + end tell + repeat with childRef in children + set matchRef to my findButton(childRef) + if matchRef is not missing value then return matchRef + end repeat + return missing value +end findButton + +on processForPID(targetPID) + tell application "System Events" + repeat with processRef in application processes + try + if (unix id of processRef as integer) is targetPID then return processRef + end try + end repeat + end tell + return missing value +end processForPID + +on findInstallButton(processRef) + tell application "System Events" + try + set processWindows to windows of processRef + on error + set processWindows to {} + end try + end tell + repeat with windowRef in processWindows + set matchRef to my findButton(windowRef) + if matchRef is not missing value then return matchRef + end repeat + return missing value +end findInstallButton + +on waitForInstallButton(targetPID, timeoutSeconds) + set deadline to (current date) + timeoutSeconds + set checkedAccessibility to false + repeat while (current date) is less than deadline + set processRef to my processForPID(targetPID) + if processRef is not missing value then + if not checkedAccessibility then + tell application "System Events" + try + count of UI elements of processRef + on error errorMessage number errorNumber + error "macOS Accessibility is unavailable (" & errorNumber & "): " & errorMessage + end try + end tell + set checkedAccessibility to true + end if + set buttonRef to my findInstallButton(processRef) + if buttonRef is not missing value then return buttonRef + end if + delay pollInterval + end repeat + error "Sparkle install prompt did not appear before timeout" +end waitForInstallButton + +on waitForMainWindow(targetPID, timeoutSeconds) + set deadline to (current date) + timeoutSeconds + repeat while (current date) is less than deadline + set processRef to my processForPID(targetPID) + if processRef is not missing value then + tell application "System Events" + try + repeat with windowRef in windows of processRef + if visible of windowRef and subrole of windowRef is "AXStandardWindow" then + set frontmost of processRef to true + return "main window ready" + end if + end repeat + end try + end tell + end if + delay pollInterval + end repeat + error "Lantern did not expose a main window before timeout" +end waitForMainWindow + +on installUntilExit(targetPID, timeoutSeconds) + set deadline to (current date) + timeoutSeconds + set pressed to false + repeat while (current date) is less than deadline + set processRef to my processForPID(targetPID) + if processRef is missing value then return "original process exited" + if not pressed then + set buttonRef to my findInstallButton(processRef) + if buttonRef is not missing value then + tell application "System Events" + set buttonName to name of buttonRef as text + perform action "AXPress" of buttonRef + end tell + set pressed to true + log "[E2E] pressed Sparkle " & buttonName + end if + end if + delay pollInterval + end repeat + error "Lantern did not exit after accepting the Sparkle update" +end installUntilExit + +on positiveInteger(valueText, fieldName) + try + set parsedValue to valueText as integer + on error + error fieldName & " must be an integer" + end try + if parsedValue < 1 then error fieldName & " must be positive" + return parsedValue +end positiveInteger + +on run argv + if (count of argv) is not 3 then error "action, PID, and timeout are required" + set actionName to item 1 of argv + set targetPID to my positiveInteger(item 2 of argv, "PID") + set timeoutSeconds to my positiveInteger(item 3 of argv, "timeout") + + if actionName is "wait-prompt" then + set buttonRef to my waitForInstallButton(targetPID, timeoutSeconds) + tell application "System Events" to return name of buttonRef as text + end if + if actionName is "install-until-exit" then + return my installUntilExit(targetPID, timeoutSeconds) + end if + if actionName is "wait-main" then + return my waitForMainWindow(targetPID, timeoutSeconds) + end if + error "unknown action: " & actionName +end run diff --git a/.github/scripts/sign_macos_update.sh b/.github/scripts/sign_macos_update.sh new file mode 100755 index 0000000000..dccc260b07 --- /dev/null +++ b/.github/scripts/sign_macos_update.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +installer_path="${1:-}" +signature_path="${2:-}" +signer_path="${3:-macos/Pods/Sparkle/bin/sign_update}" + +if [[ -z "$installer_path" || -z "$signature_path" ]]; then + printf 'Usage: %s [sign-update-path]\n' "$0" >&2 + exit 2 +fi +if [[ -z "${SPARKLE_ED_PRIVATE_KEY:-}" ]]; then + printf 'SPARKLE_ED_PRIVATE_KEY is required for macOS update signing\n' >&2 + exit 1 +fi +if [[ ! -f "$installer_path" ]]; then + printf 'macOS installer not found: %s\n' "$installer_path" >&2 + exit 1 +fi +if [[ ! -x "$signer_path" ]]; then + printf 'Sparkle signer not found or not executable: %s\n' "$signer_path" >&2 + exit 1 +fi + +mkdir -p "$(dirname "$signature_path")" +temporary_signature="$(mktemp "${signature_path}.tmp.XXXXXX")" +trap 'rm -f "$temporary_signature"' EXIT + +signature="$({ + printf '%s' "$SPARKLE_ED_PRIVATE_KEY" \ + | "$signer_path" --ed-key-file - -p "$installer_path" +} | tr -d '\r\n')" +if [[ -z "$signature" ]]; then + printf 'Sparkle produced an empty macOS update signature\n' >&2 + exit 1 +fi + +printf '%s' "$SPARKLE_ED_PRIVATE_KEY" \ + | "$signer_path" --verify --ed-key-file - "$installer_path" "$signature" + +printf '%s\n' "$signature" >"$temporary_signature" +mv "$temporary_signature" "$signature_path" +trap - EXIT diff --git a/.github/scripts/sign_windows_update.ps1 b/.github/scripts/sign_windows_update.ps1 new file mode 100644 index 0000000000..a7ac2d79d6 --- /dev/null +++ b/.github/scripts/sign_windows_update.ps1 @@ -0,0 +1,131 @@ +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)] + [string]$InstallerPath, + + [Parameter(Mandatory = $true)] + [string]$SignaturePath, + + [string]$PrivateKey = $env:SPARKLE_ED_PRIVATE_KEY, + + [string]$ResourcePath = "windows\runner\Runner.rc", + + [string]$MacOSInfoPlistPath = "macos\Runner\Info.plist", + + [string]$SignerPath = "", + + [string]$TemporaryDirectory = $env:RUNNER_TEMP +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +function Assert-NativeCommandSucceeded { + param([Parameter(Mandatory = $true)][string]$Message) + + if ($LASTEXITCODE -ne 0) { + throw "$Message (exit code $LASTEXITCODE)" + } +} + +if ([string]::IsNullOrWhiteSpace($PrivateKey)) { + throw "SPARKLE_ED_PRIVATE_KEY is required for Windows update signing" +} +if (-not (Test-Path -LiteralPath $InstallerPath -PathType Leaf)) { + throw "Windows installer not found: $InstallerPath" +} +if (-not (Test-Path -LiteralPath $ResourcePath -PathType Leaf)) { + throw "Windows resource file not found: $ResourcePath" +} +if (-not (Test-Path -LiteralPath $MacOSInfoPlistPath -PathType Leaf)) { + throw "macOS Info.plist not found: $MacOSInfoPlistPath" +} + +$resourceContents = Get-Content -LiteralPath $ResourcePath -Raw +$resourceMatch = [regex]::Match( + $resourceContents, + 'EdDSAPub\s+EDDSA\s+\{"([A-Za-z0-9+/=]+)"\}' +) +if (-not $resourceMatch.Success) { + throw "EdDSAPub resource not found in $ResourcePath" +} +$publicKey = $resourceMatch.Groups[1].Value + +$infoPlistContents = Get-Content -LiteralPath $MacOSInfoPlistPath -Raw +$infoPlistMatch = [regex]::Match( + $infoPlistContents, + '\s*SUPublicEDKey\s*\s*\s*([^<\s]+)\s*' +) +if (-not $infoPlistMatch.Success) { + throw "SUPublicEDKey not found in $MacOSInfoPlistPath" +} +if ($infoPlistMatch.Groups[1].Value -ne $publicKey) { + throw "Windows and macOS update public keys do not match" +} + +if ([string]::IsNullOrWhiteSpace($SignerPath)) { + $signerRoot = "windows\flutter\ephemeral\.plugin_symlinks\auto_updater_windows\windows" + if (-not (Test-Path -LiteralPath $signerRoot -PathType Container)) { + throw "WinSparkle plugin directory not found: $signerRoot" + } + $signerCandidates = @( + Get-ChildItem -LiteralPath $signerRoot -Filter "winsparkle-tool.exe" -File -Recurse + ) + if ($signerCandidates.Count -ne 1) { + throw "Expected one WinSparkle signer under $signerRoot, found $($signerCandidates.Count)" + } + $SignerPath = $signerCandidates[0].FullName +} +if (-not (Test-Path -LiteralPath $SignerPath -PathType Leaf)) { + throw "WinSparkle signer not found: $SignerPath" +} +if ([string]::IsNullOrWhiteSpace($TemporaryDirectory)) { + $TemporaryDirectory = [System.IO.Path]::GetTempPath() +} + +$null = New-Item -ItemType Directory -Force -Path $TemporaryDirectory +$signaturePath = [System.IO.Path]::GetFullPath($SignaturePath) +$signatureDirectory = Split-Path -Parent $signaturePath +$null = New-Item -ItemType Directory -Force -Path $signatureDirectory + +$temporaryPrefix = Join-Path $TemporaryDirectory "winsparkle-$([guid]::NewGuid().ToString('N'))" +$privateKeyPath = "$temporaryPrefix-private.key" +$temporarySignaturePath = "$temporaryPrefix-signature.txt" + +try { + [System.IO.File]::WriteAllText( + $privateKeyPath, + $PrivateKey.Trim(), + [System.Text.Encoding]::ASCII + ) + + $signatureOutput = & $SignerPath sign --private-key-file $privateKeyPath $InstallerPath + Assert-NativeCommandSucceeded "WinSparkle signing failed" + + $signature = ($signatureOutput -join "").Trim() + if ([string]::IsNullOrWhiteSpace($signature)) { + throw "WinSparkle produced an empty Windows update signature" + } + try { + $decodedSignature = [Convert]::FromBase64String($signature) + } + catch { + throw "WinSparkle produced an invalid base64 signature" + } + if ($decodedSignature.Length -ne 64) { + throw "WinSparkle produced an invalid EdDSA signature length: $($decodedSignature.Length)" + } + + & $SignerPath verify --public-key $publicKey --signature $signature $InstallerPath + Assert-NativeCommandSucceeded "Windows update signature verification failed" + + [System.IO.File]::WriteAllText( + $temporarySignaturePath, + "$signature`n", + [System.Text.Encoding]::ASCII + ) + Move-Item -LiteralPath $temporarySignaturePath -Destination $signaturePath -Force +} +finally { + Remove-Item -Force -ErrorAction SilentlyContinue $privateKeyPath, $temporarySignaturePath +} diff --git a/.github/scripts/windows_auto_update_smoke.ps1 b/.github/scripts/windows_auto_update_smoke.ps1 new file mode 100644 index 0000000000..e9bea277d0 --- /dev/null +++ b/.github/scripts/windows_auto_update_smoke.ps1 @@ -0,0 +1,352 @@ +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest + +$AppDirectory = 'C:\Program Files\Lantern' +$AppExecutable = Join-Path $AppDirectory 'lantern.exe' +$DataDirectory = 'C:\ProgramData\Lantern' +$HandoffPath = Join-Path $DataDirectory 'E2E\auto-update-handoff.json' +$FixtureDirectory = $env:FIXTURE_APP_DIR +$TargetJson = $env:TARGET_JSON +$AppcastXml = $env:APPCAST_XML +$ArtifactDirectory = $env:ARTIFACT_DIR +$UiTimeout = if ($env:UI_TIMEOUT_SECONDS) { [int]$env:UI_TIMEOUT_SECONDS } else { 120 } +$UpdateTimeout = if ($env:UPDATE_TIMEOUT_SECONDS) { [int]$env:UPDATE_TIMEOUT_SECONDS } else { 600 } +$script:OriginalPid = 0 +$script:RelaunchedPid = 0 +$script:Result = 'failure' + +Add-Type -AssemblyName System.Drawing +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName UIAutomationClient +Add-Type -AssemblyName UIAutomationTypes + +function Write-E2E([string]$Message) { + Write-Host "[E2E] $Message" +} + +function Assert-SmokeGuard { + if ($env:CI -ne 'true' -or $env:GITHUB_ACTIONS -ne 'true' -or + $env:LANTERN_AUTO_UPDATE_SMOKE -ne 'true') { + throw 'This destructive smoke requires its explicit GitHub Actions guard.' + } + if ($AppDirectory -ne 'C:\Program Files\Lantern' -or + $DataDirectory -ne 'C:\ProgramData\Lantern') { + throw 'Refusing to use unexpected Lantern paths.' + } + foreach ($path in @($TargetJson, $AppcastXml)) { + if ([string]::IsNullOrWhiteSpace($path) -or + -not (Test-Path -LiteralPath $path -PathType Leaf)) { + throw "Required smoke input was not found: $path" + } + } + if ([string]::IsNullOrWhiteSpace($FixtureDirectory) -or + -not (Test-Path -LiteralPath $FixtureDirectory -PathType Container) -or + -not (Test-Path -LiteralPath (Join-Path $FixtureDirectory 'lantern.exe') -PathType Leaf)) { + throw "Signed fixture app was not found: $FixtureDirectory" + } + if ([string]::IsNullOrWhiteSpace($ArtifactDirectory) -or + $UiTimeout -lt 1 -or $UpdateTimeout -lt 1) { + throw 'Artifact directory and positive timeouts are required.' + } +} + +function Get-LanternProcesses { + @(Get-Process -Name lantern -ErrorAction SilentlyContinue | Where-Object { + try { $_.Path -eq $AppExecutable } catch { $false } + }) +} + +function Invoke-Checked { + param( + [Parameter(Mandatory = $true)][string]$FilePath, + [string[]]$Arguments = @(), + [Parameter(Mandatory = $true)][string]$Description, + [int]$TimeoutSeconds = 180 + ) + Write-E2E "${Description}: $FilePath $($Arguments -join ' ')" + $process = Start-Process $FilePath -ArgumentList $Arguments -PassThru + if (-not $process.WaitForExit($TimeoutSeconds * 1000)) { + Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue + throw "$Description timed out after $TimeoutSeconds seconds" + } + if ($process.ExitCode -ne 0) { + throw "$Description failed with exit code $($process.ExitCode)" + } +} + +function Reset-Lantern { + Assert-SmokeGuard + Get-LanternProcesses | Stop-Process -Force -ErrorAction SilentlyContinue + $uninstaller = Get-ChildItem $AppDirectory -Filter 'unins*.exe' -File -ErrorAction SilentlyContinue | + Sort-Object Name | Select-Object -First 1 + if ($uninstaller) { + Invoke-Checked $uninstaller.FullName @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/SP-') ` + 'Uninstalling existing Lantern' + } else { + $service = Join-Path $AppDirectory 'lanternd.exe' + if (Test-Path -LiteralPath $service) { + & $service uninstall 2>&1 | Out-Null + } + } + Remove-Item -LiteralPath $AppDirectory, $DataDirectory -Recurse -Force -ErrorAction SilentlyContinue +} + +function Install-Fixture { + Reset-Lantern + New-Item -ItemType Directory -Path $AppDirectory -Force | Out-Null + Copy-Item -Path (Join-Path $FixtureDirectory '*') -Destination $AppDirectory -Recurse -Force + Invoke-Checked (Join-Path $AppDirectory 'lanternd.exe') @('install') 'Registering fixture service' +} + +function Save-Screenshot([string]$Name) { + try { + $bounds = [System.Windows.Forms.SystemInformation]::VirtualScreen + $bitmap = [System.Drawing.Bitmap]::new($bounds.Width, $bounds.Height) + $graphics = [System.Drawing.Graphics]::FromImage($bitmap) + try { + $graphics.CopyFromScreen($bounds.Location, [System.Drawing.Point]::Empty, $bounds.Size) + $bitmap.Save((Join-Path $ArtifactDirectory "$Name.png")) + } finally { + $graphics.Dispose() + $bitmap.Dispose() + } + } catch { + $_ | Out-String | Set-Content (Join-Path $ArtifactDirectory "$Name-screenshot-error.txt") + } +} + +function Assert-AppVersion { + param( + [string]$Name, + [int]$ExpectedBuild, + [string]$ExpectedDisplay + ) + $info = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($AppExecutable) + $display = ($info.ProductVersion -split '\+', 2)[0] + $signature = Get-AuthenticodeSignature -LiteralPath $AppExecutable + $subject = if ($null -eq $signature.SignerCertificate) { '' } else { $signature.SignerCertificate.Subject } + @( + "display_version=$display" + "raw_product_version=$($info.ProductVersion)" + "build_number=$($info.FilePrivatePart)" + "authenticode_status=$($signature.Status)" + "authenticode_subject=$subject" + ) | Set-Content (Join-Path $ArtifactDirectory "versions-$Name.txt") + if ($display -ne $ExpectedDisplay -or $info.FilePrivatePart -ne $ExpectedBuild) { + throw "$Name version $display+$($info.FilePrivatePart) did not match $ExpectedDisplay+$ExpectedBuild" + } + if ($signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid) { + throw "$Name Lantern executable Authenticode status is $($signature.Status)" + } +} + +function Get-Window([string[]]$NamePrefixes) { + $condition = [System.Windows.Automation.PropertyCondition]::new( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::Window + ) + foreach ($window in [System.Windows.Automation.AutomationElement]::RootElement.FindAll( + [System.Windows.Automation.TreeScope]::Children, $condition)) { + $name = [string]$window.Current.Name + foreach ($prefix in $NamePrefixes) { + if ($name -eq $prefix -or $name.StartsWith("$prefix ")) { return $window } + } + } + return $null +} + +function Get-Button($Window, [string[]]$Names) { + if ($null -eq $Window) { return $null } + $condition = [System.Windows.Automation.PropertyCondition]::new( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::Button + ) + foreach ($button in $Window.FindAll([System.Windows.Automation.TreeScope]::Descendants, $condition)) { + $name = ([string]$button.Current.Name).Replace('&', '') + if ($button.Current.IsEnabled -and $Names -contains $name) { + return $button + } + } + return $null +} + +function Press-Button($Button) { + $pattern = $null + if (-not $Button.TryGetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern, [ref]$pattern)) { + throw "Button '$($Button.Current.Name)' cannot be invoked" + } + Write-E2E "pressing $($Button.Current.Name)" + ([System.Windows.Automation.InvokePattern]$pattern).Invoke() +} + +function Save-UiTree([string]$Name) { + $lines = foreach ($windowName in @('Software Update', 'Setup - Lantern', 'Lantern Setup')) { + $window = Get-Window @($windowName) + if ($window) { + "WINDOW name='$($window.Current.Name)' pid=$($window.Current.ProcessId)" + $condition = [System.Windows.Automation.PropertyCondition]::new( + [System.Windows.Automation.AutomationElement]::ControlTypeProperty, + [System.Windows.Automation.ControlType]::Button + ) + foreach ($button in $window.FindAll([System.Windows.Automation.TreeScope]::Descendants, $condition)) { + " BUTTON name='$($button.Current.Name)' enabled=$($button.Current.IsEnabled)" + } + } + } + $lines | Set-Content (Join-Path $ArtifactDirectory "ui-$Name.txt") +} + +function Wait-ForUpdatePrompt { + $deadline = [DateTime]::UtcNow.AddSeconds($UiTimeout) + while ([DateTime]::UtcNow -lt $deadline) { + $button = Get-Button (Get-Window @('Software Update')) @('Install update', 'Get update') + if ($button) { return $button } + Start-Sleep -Milliseconds 250 + } + Save-UiTree 'prompt-timeout' + throw 'WinSparkle update prompt did not appear before timeout' +} + +function Get-DownloadedInstaller([string]$ExpectedName, [long]$ExpectedLength) { + @( + Get-ChildItem $env:TEMP -Filter 'Update-*' -Directory -ErrorAction SilentlyContinue | + ForEach-Object { + Get-ChildItem $_.FullName -Filter $ExpectedName -File -ErrorAction SilentlyContinue + } | + Where-Object Length -eq $ExpectedLength | + Sort-Object LastWriteTimeUtc -Descending + ) | Select-Object -First 1 +} + +function Install-Update( + [int]$OriginalPid, + [string]$ExpectedInstallerName, + [long]$ExpectedInstallerLength, + [int]$ExpectedBuild +) { + $deadline = [DateTime]::UtcNow.AddSeconds($UpdateTimeout) + $launchRequested = $false + $originalExited = $false + while ([DateTime]::UtcNow -lt $deadline) { + $installer = Get-Window @('Setup - Lantern', 'Lantern Setup') + $button = Get-Button $installer @('Next >', 'Next', 'Install', 'Finish', 'Yes') + if ($button) { + Press-Button $button + Start-Sleep -Milliseconds 500 + } elseif (-not $launchRequested) { + $download = Get-DownloadedInstaller $ExpectedInstallerName $ExpectedInstallerLength + if ($download) { + $button = Get-Button (Get-Window @('Software Update')) @('Install update', 'Run installer') + if ($button) { + Write-E2E "download complete; launching $($download.Name)" + Press-Button $button + $launchRequested = $true + Start-Sleep -Milliseconds 500 + } + } + } + if (-not $originalExited -and -not (Get-Process -Id $OriginalPid -ErrorAction SilentlyContinue)) { + $originalExited = $true + Write-E2E "original Lantern process $OriginalPid exited" + } + if ($originalExited -and (Test-Path -LiteralPath $AppExecutable)) { + try { + $installedBuild = [System.Diagnostics.FileVersionInfo]::GetVersionInfo($AppExecutable).FilePrivatePart + if ($installedBuild -eq $ExpectedBuild) { + $relaunchCandidates = @(Get-LanternProcesses | Where-Object Id -ne $OriginalPid) + foreach ($relaunched in $relaunchCandidates) { + $relaunched.Refresh() + if ($relaunched.MainWindowHandle -ne 0) { return $relaunched } + } + } + } catch { + # The installer may be replacing the executable while this poll runs. + } + } + Start-Sleep -Milliseconds 250 + } + Save-UiTree 'install-timeout' + throw 'WinSparkle did not install and relaunch Lantern before timeout' +} + +function Save-Diagnostics { + @( + "result=$script:Result" + "original_pid=$script:OriginalPid" + "relaunched_pid=$script:RelaunchedPid" + ) | Set-Content (Join-Path $ArtifactDirectory 'result.txt') + Get-CimInstance Win32_Process | Where-Object Name -Match '^(lantern|lanternd|lantern-installer.*|Update|unins\d*)\.exe$' | + Select-Object ProcessId, ParentProcessId, Name, ExecutablePath, CommandLine | + Format-List | Out-String -Width 4096 | + Set-Content (Join-Path $ArtifactDirectory 'processes-final.txt') + Save-UiTree 'final' + $logs = Join-Path $DataDirectory 'Logs' + if (Test-Path -LiteralPath $logs) { + Copy-Item $logs (Join-Path $ArtifactDirectory 'lantern-logs') -Recurse -Force + } + Get-ChildItem $env:TEMP -Filter 'Update-*' -Directory -ErrorAction SilentlyContinue | + ForEach-Object { Get-ChildItem $_.FullName -Recurse -ErrorAction SilentlyContinue } | + Select-Object FullName, Length, LastWriteTime | + Format-Table -AutoSize | Out-String -Width 4096 | + Set-Content (Join-Path $ArtifactDirectory 'winsparkle-files.txt') +} + +Assert-SmokeGuard +New-Item -ItemType Directory -Path $ArtifactDirectory -Force | Out-Null +Copy-Item $AppcastXml (Join-Path $ArtifactDirectory 'appcast.xml') +Copy-Item $TargetJson (Join-Path $ArtifactDirectory 'resolved-target.json') +$target = Get-Content $TargetJson -Raw | ConvertFrom-Json +$targetBuild = [int]$target.target_build +$fixtureBuild = [int]$target.fixture_build +$displayVersion = [string]$target.display_version +$targetUri = [Uri][string]$target.artifact_url +$targetInstallerName = [IO.Path]::GetFileName($targetUri.AbsolutePath) +$targetInstallerLength = [long]$target.artifact_length + +try { + Install-Fixture + Assert-AppVersion 'fixture' $fixtureBuild $displayVersion + Save-Screenshot 'before' + + Write-E2E 'running the Flutter auto-update robot against the installed fixture' + & flutter drive --profile "--use-application-binary=$AppExecutable" --keep-app-running ` + --driver=test_driver/integration_test.dart ` + --target=integration_test/auto_update/desktop_auto_update_smoke_test.dart ` + --device-id=windows *>&1 | + Tee-Object -FilePath (Join-Path $ArtifactDirectory 'flutter-drive.log') + if ($LASTEXITCODE -ne 0) { throw "Flutter auto-update robot failed with exit code $LASTEXITCODE" } + + if (-not (Test-Path -LiteralPath $HandoffPath -PathType Leaf)) { + throw 'Flutter test completed without creating its native handoff.' + } + $handoff = Get-Content $HandoffPath -Raw | ConvertFrom-Json + Copy-Item $HandoffPath (Join-Path $ArtifactDirectory 'auto-update-handoff.json') + $script:OriginalPid = [int]$handoff.pid + if ([string]$handoff.build_number -ne [string]$fixtureBuild -or + [string]$handoff.display_version -ne $displayVersion) { + throw 'Flutter test ran against the wrong fixture version.' + } + $original = Get-Process -Id $script:OriginalPid -ErrorAction Stop + if ($original.Path -ne $AppExecutable) { + throw "Flutter handoff PID $script:OriginalPid is not the installed Lantern app" + } + + $installButton = Wait-ForUpdatePrompt + Save-UiTree 'prompt' + Save-Screenshot 'prompt' + Press-Button $installButton + $relaunched = Install-Update ` + $script:OriginalPid ` + $targetInstallerName ` + $targetInstallerLength ` + $targetBuild + $script:RelaunchedPid = $relaunched.Id + Assert-AppVersion 'updated' $targetBuild $displayVersion + Save-Screenshot 'after' + $script:Result = 'success' + Write-E2E "auto-update smoke passed: build $fixtureBuild -> $targetBuild" +} finally { + try { Save-Diagnostics } catch { Write-Warning "Unable to save all diagnostics: $_" } + try { Save-Screenshot 'final' } catch { Write-Warning "Unable to save final screenshot: $_" } + try { Reset-Lantern } catch { Write-Warning "Unable to clean up Lantern fixture: $_" } +} diff --git a/.github/workflows/android-compile-check.yml b/.github/workflows/android-compile-check.yml index 3df3f4ee47..8ca81b7c56 100644 --- a/.github/workflows/android-compile-check.yml +++ b/.github/workflows/android-compile-check.yml @@ -98,7 +98,7 @@ jobs: make android-env >> "$GITHUB_ENV" - name: Install Flutter - uses: subosito/flutter-action@v2.22.0 + uses: subosito/flutter-action@v2.23.0 with: channel: stable flutter-version-file: .github/flutter-version.yaml diff --git a/.github/workflows/app-smoke-tests.yml b/.github/workflows/app-smoke-tests.yml index c2dd4f1ae2..ad1ef4770e 100644 --- a/.github/workflows/app-smoke-tests.yml +++ b/.github/workflows/app-smoke-tests.yml @@ -22,6 +22,7 @@ on: type: choice options: - vpn-smoke # connect/disconnect smoke only (validates the public IP changes) — the fast confidence check + - auto-update # desktop: install a lower signed fixture and update it from the isolated staging feed - payment-smoke # Stripe Checkout rendering and payment-to-Pro conversion - all # every suite the platform supports default: vpn-smoke @@ -48,13 +49,24 @@ concurrency: cancel-in-progress: false jobs: + validate-inputs: + runs-on: ubuntu-latest + steps: + - name: Validate auto-update selection + if: ${{ inputs.tests == 'auto-update' && !contains(fromJSON('["all", "macos", "windows"]'), inputs.platforms) }} + shell: bash + run: | + echo '::error title=Invalid smoke selection::auto-update requires platforms=all, macos, or windows' + exit 2 + # Android builds its own versioned APKs and runs on Firebase Test Lab, so it # doesn't need the desktop version stamping from `prepare`. The vpn-smoke # tier builds only the VPN suite and runs it on one virtual device with no # retries; `all` builds the full aggregator on the script's two-device # matrix (physical Pixel 8 + Arm virtual) with retries for flaky tests. android: - if: ${{ contains(fromJSON('["all", "android", ""]'), inputs.platforms) && (inputs.tests || 'all') != 'payment-smoke' }} + needs: validate-inputs + if: ${{ contains(fromJSON('["all", "android", ""]'), inputs.platforms) && !contains(fromJSON('["auto-update", "payment-smoke"]'), inputs.tests || 'all') }} uses: ./.github/workflows/firebase-test-lab.yml secrets: inherit with: @@ -69,6 +81,8 @@ jobs: flaky_attempts: ${{ (inputs.tests || 'all') == 'all' && '2' || '' }} prepare: + needs: validate-inputs + if: ${{ inputs.tests != 'auto-update' }} runs-on: ubuntu-latest outputs: version: ${{ steps.meta.outputs.version }} @@ -108,7 +122,7 @@ jobs: # to the optional suites: vpn-smoke leaves them off, `all` turns them on. linux: needs: prepare - if: ${{ contains(fromJSON('["all", "linux", ""]'), inputs.platforms) && (inputs.tests || 'all') != 'payment-smoke' }} + if: ${{ contains(fromJSON('["all", "linux", ""]'), inputs.platforms) && !contains(fromJSON('["auto-update", "payment-smoke"]'), inputs.tests || 'all') }} uses: ./.github/workflows/build-linux.yml secrets: inherit with: @@ -124,7 +138,7 @@ jobs: # runs only when asked for explicitly. macos: needs: prepare - if: ${{ inputs.platforms == 'macos' }} + if: ${{ inputs.platforms == 'macos' && inputs.tests != 'auto-update' }} uses: ./.github/workflows/build-macos.yml secrets: inherit with: @@ -135,9 +149,18 @@ jobs: run_connect_smoke: ${{ (inputs.tests || 'all') != 'payment-smoke' }} run_payment_smoke: ${{ contains(fromJSON('["all", "payment-smoke"]'), inputs.tests || 'all') }} + # This is deliberately separate from the regular macOS build job: it + # replaces /Applications/Lantern.app and verifies Sparkle's relaunch. It is + # selected explicitly, and never runs in the nightly sweep. + macos-auto-update: + needs: validate-inputs + if: ${{ contains(fromJSON('["all", "macos"]'), inputs.platforms) && inputs.tests == 'auto-update' }} + uses: ./.github/workflows/macos-auto-update-smoke.yml + secrets: inherit + windows: needs: prepare - if: ${{ contains(fromJSON('["all", "windows", ""]'), inputs.platforms) }} + if: ${{ contains(fromJSON('["all", "windows", ""]'), inputs.platforms) && inputs.tests != 'auto-update' }} uses: ./.github/workflows/build-windows.yml secrets: inherit with: @@ -150,3 +173,9 @@ jobs: run_auth_smoke: ${{ (inputs.tests || 'all') == 'all' }} run_split_tunnel_website_smoke: ${{ (inputs.tests || 'all') == 'all' }} run_payment_smoke: ${{ contains(fromJSON('["all", "payment-smoke"]'), inputs.tests || 'all') }} + + windows-auto-update: + needs: validate-inputs + if: ${{ contains(fromJSON('["all", "windows"]'), inputs.platforms) && inputs.tests == 'auto-update' }} + uses: ./.github/workflows/windows-auto-update-smoke.yml + secrets: inherit diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index 924fbbcdf3..81ee50d383 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -91,7 +91,7 @@ jobs: make android-env >> "$GITHUB_ENV" - name: Install Flutter - uses: subosito/flutter-action@v2.22.0 + uses: subosito/flutter-action@v2.23.0 with: channel: stable flutter-version-file: .github/flutter-version.yaml diff --git a/.github/workflows/build-auto-update-fixtures.yml b/.github/workflows/build-auto-update-fixtures.yml new file mode 100644 index 0000000000..db5edc4561 --- /dev/null +++ b/.github/workflows/build-auto-update-fixtures.yml @@ -0,0 +1,178 @@ +name: Build auto-update fixture targets + +on: + workflow_dispatch: + inputs: + publish: + description: "Publish the signed targets as a fixture-repository prerelease" + required: false + type: boolean + default: false + workflow_call: + inputs: + publish: + description: "Publish the signed targets as a fixture-repository prerelease" + required: false + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: build-auto-update-fixtures + cancel-in-progress: false + +jobs: + prepare: + runs-on: ubuntu-latest + outputs: + display_version: ${{ steps.fixture.outputs.display_version }} + release_tag: ${{ steps.fixture.outputs.release_tag }} + release_version: ${{ steps.fixture.outputs.release_version }} + target_build: ${{ steps.fixture.outputs.target_build }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Prepare target version + id: fixture + shell: bash + run: | + set -euo pipefail + DISPLAY_VERSION="$(sed -nE 's/^version:[[:space:]]*([^+[:space:]]+).*/\1/p' pubspec.yaml)" + TARGET_BUILD="$((1000 + GITHUB_RUN_NUMBER))" + RELEASE_VERSION="${DISPLAY_VERSION}-beta.e2e.${GITHUB_RUN_ID}" + RELEASE_TAG="v${RELEASE_VERSION}" + [[ "$TARGET_BUILD" =~ ^[0-9]+$ && "$TARGET_BUILD" -gt 1 && "$TARGET_BUILD" -le 65535 ]] + sed -i.bak -E "s/^version:.*/version: ${DISPLAY_VERSION}+${TARGET_BUILD}/" pubspec.yaml + rm pubspec.yaml.bak + { + echo "display_version=$DISPLAY_VERSION" + echo "release_tag=$RELEASE_TAG" + echo "release_version=$RELEASE_VERSION" + echo "target_build=$TARGET_BUILD" + } >> "$GITHUB_OUTPUT" + + - name: Upload target pubspec + uses: actions/upload-artifact@v4 + with: + name: auto-update-target-pubspec + path: pubspec.yaml + retention-days: 2 + + build-macos: + needs: prepare + permissions: + contents: read + id-token: write + uses: ./.github/workflows/build-macos.yml + secrets: inherit + with: + version: ${{ needs.prepare.outputs.display_version }} + build_type: beta + installer_base_name: lantern-installer + pubspec_artifact: auto-update-target-pubspec + auto_update_e2e: true + sign_update_artifact: true + run_connect_smoke: false + run_payment_smoke: false + + build-windows: + needs: prepare + permissions: + contents: read + id-token: write + uses: ./.github/workflows/build-windows.yml + secrets: inherit + with: + version: ${{ needs.prepare.outputs.display_version }} + build_type: beta + installer_base_name: lantern-installer + pubspec_artifact: auto-update-target-pubspec + auto_update_e2e: true + sign_update_artifact: true + run_installer_smoke: false + run_connect_smoke: false + run_split_tunnel_website_smoke: false + run_config_url_smoke: false + run_auth_smoke: false + run_payment_smoke: false + + assemble: + needs: + - prepare + - build-macos + - build-windows + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Download signed installers + uses: actions/download-artifact@v4 + with: + pattern: lantern-installer-* + path: fixture-downloads + + - name: Assemble and verify fixture release + shell: bash + env: + ASSET_BASE_URL: https://github.com/getlantern/lantern-update-fixtures/releases/download/${{ needs.prepare.outputs.release_tag }} + RELEASE_VERSION: ${{ needs.prepare.outputs.release_version }} + TARGET_BUILD: ${{ needs.prepare.outputs.target_build }} + run: | + set -euo pipefail + mkdir -p fixture-release/artifacts fixture-release/metadata fixture-release/signatures + find fixture-downloads -type f -name 'lantern-installer-beta.dmg' -exec cp {} fixture-release/artifacts/ \; + find fixture-downloads -type f -name 'lantern-installer-beta.exe' -exec cp {} fixture-release/artifacts/ \; + find fixture-downloads -type f -name '*.sparkle-signature' -exec cp {} fixture-release/signatures/ \; + test -f fixture-release/artifacts/lantern-installer-beta.dmg + test -f fixture-release/artifacts/lantern-installer-beta.exe + test -f fixture-release/signatures/lantern-installer-beta.dmg.sparkle-signature + test -f fixture-release/signatures/lantern-installer-beta.exe.sparkle-signature + python scripts/ci/generate_update_metadata.py \ + --build-type beta \ + --version "$RELEASE_VERSION" \ + --sparkle-version "$TARGET_BUILD" \ + --bucket unused-fixture-bucket \ + --asset-base-url "$ASSET_BASE_URL" \ + --output-dir fixture-release/metadata \ + --sparkle-signature-dir fixture-release/signatures \ + fixture-release/artifacts/* + python scripts/ci/verify_fixture_update_artifacts.py \ + --artifact-dir fixture-release/artifacts \ + --metadata-dir fixture-release/metadata \ + --asset-base-url "$ASSET_BASE_URL" + cp fixture-release/metadata/* fixture-release/artifacts/ + + - name: Upload verified fixture release + uses: actions/upload-artifact@v4 + with: + name: auto-update-fixture-release + path: fixture-release/artifacts + if-no-files-found: error + retention-days: 7 + + - name: Publish fixture prerelease + if: ${{ inputs.publish }} + shell: bash + env: + GH_TOKEN: ${{ secrets.UPDATE_FIXTURES_GH_TOKEN }} + RELEASE_TAG: ${{ needs.prepare.outputs.release_tag }} + run: | + set -euo pipefail + [[ -n "$GH_TOKEN" ]] || { + echo '::error title=Missing fixture token::Set UPDATE_FIXTURES_GH_TOKEN before publishing.' + exit 1 + } + gh release create "$RELEASE_TAG" \ + --repo getlantern/lantern-update-fixtures \ + --prerelease \ + --title "Lantern auto-update fixture $RELEASE_TAG" \ + --notes "Synthetic signed update target for Lantern E2E tests. Safe to delete after validation." \ + fixture-release/artifacts/* diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml index cba75e81b3..4d43b15393 100644 --- a/.github/workflows/build-ios.yml +++ b/.github/workflows/build-ios.yml @@ -40,7 +40,7 @@ jobs: cache: true - name: Install Flutter - uses: subosito/flutter-action@v2.22.0 + uses: subosito/flutter-action@v2.23.0 with: channel: stable flutter-version-file: .github/flutter-version.yaml diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index c9edf0c3cb..936306f684 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -138,7 +138,7 @@ jobs: echo "FLUTTER_VERSION=$FLUTTER_VERSION" >> "$GITHUB_ENV" - name: Install Flutter (amd64) - uses: subosito/flutter-action@v2.22.0 + uses: subosito/flutter-action@v2.23.0 if: ${{ matrix.arch == 'amd64' }} with: channel: stable diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml index 387d82487e..ef284e8c57 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -17,6 +17,8 @@ on: required: true MACOS_PROVISION_TUNNEL_BASE64: required: true + SPARKLE_ED_PRIVATE_KEY: + required: false inputs: version: required: true @@ -32,6 +34,31 @@ on: required: false type: string default: macos-15 + flutter_target: + description: "Optional Flutter entrypoint used to build the app" + required: false + type: string + default: "" + flutter_build_mode: + description: "Flutter build mode: release or profile" + required: false + type: string + default: release + pubspec_artifact: + description: "Artifact containing the version-stamped pubspec.yaml" + required: false + type: string + default: pubspec + sign_update_artifact: + description: "Generate the Sparkle signature published with this DMG" + required: false + type: boolean + default: true + auto_update_e2e: + description: "Build a fixture that reads the isolated staging appcast" + required: false + type: boolean + default: false run_connect_smoke: description: "Run the macOS connect/disconnect smoke after building" required: false @@ -77,7 +104,7 @@ jobs: - name: Download pubspec.yaml uses: actions/download-artifact@v4 with: - name: pubspec + name: ${{ inputs.pubspec_artifact }} - name: Set up Go uses: actions/setup-go@v5 @@ -108,7 +135,7 @@ jobs: ${{ runner.os }}-macos-pods- - name: Install Flutter - uses: subosito/flutter-action@v2.22.0 + uses: subosito/flutter-action@v2.23.0 with: channel: stable flutter-version-file: .github/flutter-version.yaml @@ -228,10 +255,22 @@ jobs: echo "::notice title=macOS payment smoke diagnostics::$ARTIFACT_URL" echo "[Download the macOS payment smoke screenshot and diagnostics]($ARTIFACT_URL)" >> "$GITHUB_STEP_SUMMARY" - - name: Build macOS release - run: make macos-release-ci + - name: Build macOS app + shell: bash + run: | + case "$FLUTTER_BUILD_MODE" in + release) make macos-release-ci ;; + profile) make macos-profile-ci ;; + *) + printf 'Unsupported macOS Flutter build mode: %s\n' "$FLUTTER_BUILD_MODE" >&2 + exit 2 + ;; + esac env: BUILD_TYPE: ${{ inputs.build_type }} + AUTO_UPDATE_E2E: ${{ inputs.auto_update_e2e }} + FLUTTER_BUILD_MODE: ${{ inputs.flutter_build_mode }} + FLUTTER_TARGET: ${{ inputs.flutter_target }} VERSION: ${{ inputs.version }} INSTALLER_NAME: ${{ inputs.installer_base_name }} GOMOBILECACHE: ${{ env.GOMOBILECACHE }} @@ -256,6 +295,17 @@ jobs: ditto -c -k --keepParent "$APP_PATH" "$RUNNER_TEMP/Lantern.app.zip" + - name: Sign macOS update + if: ${{ inputs.build_type != 'nightly' && inputs.sign_update_artifact }} + shell: bash + env: + SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} + DMG_PATH: ${{ inputs.installer_base_name }}${{ inputs.build_type != 'production' && format('-{0}', inputs.build_type) || '' }}.dmg + run: | + bash ./.github/scripts/sign_macos_update.sh \ + "$DMG_PATH" \ + "$RUNNER_TEMP/$(basename "$DMG_PATH").sparkle-signature" + - name: Upload macOS app uses: actions/upload-artifact@v4 with: @@ -270,6 +320,14 @@ jobs: path: ${{ inputs.installer_base_name }}${{ inputs.build_type != 'production' && format('-{0}', inputs.build_type) || '' }}.dmg retention-days: 2 + - name: Upload macOS update signature + if: ${{ inputs.build_type != 'nightly' && inputs.sign_update_artifact }} + uses: actions/upload-artifact@v4 + with: + name: lantern-installer-dmg-signature + path: ${{ runner.temp }}/${{ inputs.installer_base_name }}${{ inputs.build_type != 'production' && format('-{0}', inputs.build_type) || '' }}.dmg.sparkle-signature + retention-days: 2 + - name: Run macOS connect smoke if: ${{ inputs.run_connect_smoke }} timeout-minutes: 30 diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 23f386a78a..9d449b4eef 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -9,6 +9,8 @@ on: required: false SIGNPATH_API_TOKEN: required: false + SPARKLE_ED_PRIVATE_KEY: + required: false inputs: version: required: true @@ -19,6 +21,41 @@ on: installer_base_name: required: true type: string + flutter_target: + description: "Optional Flutter entrypoint used to build the app" + required: false + type: string + default: "" + flutter_build_mode: + description: "Flutter build mode: release or profile" + required: false + type: string + default: release + pubspec_artifact: + description: "Artifact containing the version-stamped pubspec.yaml" + required: false + type: string + default: pubspec + sign_update_artifact: + description: "Generate the Sparkle signature published with this installer" + required: false + type: boolean + default: true + auto_update_e2e: + description: "Build a fixture that reads the isolated staging appcast" + required: false + type: boolean + default: false + package_installer: + description: "Package and upload the Windows installer" + required: false + type: boolean + default: true + run_installer_smoke: + description: "Install the packaged app and run the Windows smoke suite" + required: false + type: boolean + default: true skip_signing: description: "Skip code signing (e.g. for nightly builds)" required: false @@ -77,7 +114,7 @@ jobs: - name: Download pubspec.yaml uses: actions/download-artifact@v4 with: - name: pubspec + name: ${{ inputs.pubspec_artifact }} - name: Set up Go uses: actions/setup-go@v5 @@ -128,7 +165,7 @@ jobs: echo "version=$version" >> "$GITHUB_OUTPUT" - name: Install Flutter - uses: subosito/flutter-action@v2.22.0 + uses: subosito/flutter-action@v2.23.0 with: channel: stable flutter-version: ${{ steps.flutter-version.outputs.version }} @@ -144,15 +181,8 @@ jobs: fileDir: ${{ github.workspace }} encodedString: ${{ secrets.APP_ENV }} - - name: Cache Dart pub global cache - uses: actions/cache@v4 - with: - path: ~/.pub-cache - key: ${{ runner.os }}-dart-pub-cache-${{ hashFiles('**/pubspec.lock') }} - restore-keys: | - ${{ runner.os }}-dart-pub-cache- - - name: Install Inno Setup 6 + if: ${{ inputs.package_installer }} shell: bash # The runner image preinstalls Inno Setup (currently 6.7.1). A bare # --version=6.5.0 now fails ("a newer version is already installed; use @@ -163,6 +193,7 @@ jobs: run: ./scripts/ci/choco-retry.sh -y innosetup --version=6.7.1 --allow-downgrade - name: Install unofficial Inno Setup translations + if: ${{ inputs.package_installer }} shell: pwsh # Chinese Simplified and Farsi/Persian are not bundled Inno translations; # copy the vendored .isl files into Inno's Languages dir so inno_setup.iss @@ -191,11 +222,21 @@ jobs: - name: Build Windows binaries shell: pwsh + env: + AUTO_UPDATE_E2E: ${{ inputs.auto_update_e2e }} + FLUTTER_BUILD_MODE: ${{ inputs.flutter_build_mode }} + FLUTTER_TARGET: ${{ inputs.flutter_target }} run: | - dart pub global activate fastforge - make windows-release + switch ($env:FLUTTER_BUILD_MODE) { + 'release' { make windows-release } + 'profile' { make windows-profile-ci } + default { + Write-Error "Unsupported Windows Flutter build mode: $env:FLUTTER_BUILD_MODE" + exit 2 + } + } if ($LASTEXITCODE -ne 0) { - Write-Error "make windows-release failed with exit code $LASTEXITCODE" + Write-Error "Windows build failed with exit code $LASTEXITCODE" exit $LASTEXITCODE } @@ -314,11 +355,22 @@ jobs: run: | Write-Host "Skipping embedded and installer signing for this run." + - name: Upload signed Windows app fixture + if: ${{ !inputs.package_installer }} + uses: actions/upload-artifact@v4 + with: + name: lantern-windows-app-fixture + path: build/windows/x64/runner/Release + if-no-files-found: error + retention-days: 2 + - name: Package installer + if: ${{ inputs.package_installer }} shell: pwsh env: FULL_INSTALLER_NAME: ${{ inputs.installer_base_name }}${{ inputs.build_type != 'production' && format('-{0}', inputs.build_type) || '' }} run: | + dart pub global activate fastforge fastforge package ` --platform=windows ` --targets=exe ` @@ -335,6 +387,7 @@ jobs: Move-Item "dist/$env:APP_VERSION/$env:APP_NAME-$env:APP_VERSION-windows-setup.exe" "$env:FULL_INSTALLER_NAME.exe" - name: Windows installer smoke suite + if: ${{ inputs.package_installer && inputs.run_installer_smoke }} shell: pwsh timeout-minutes: 30 env: @@ -368,7 +421,7 @@ jobs: flutter test integration_test/auth/auth_smoke_test.dart -d windows --reporter=expanded --dart-define=DISABLE_SYSTEM_TRAY=true - name: Sign installer - if: ${{ !inputs.skip_signing }} + if: ${{ inputs.package_installer && !inputs.skip_signing }} shell: pwsh env: SIGNPATH_API_TOKEN: ${{ secrets.SIGNPATH_API_TOKEN }} @@ -382,9 +435,29 @@ jobs: -ApiToken $env:SIGNPATH_API_TOKEN ` -Description "Installer - GitHub Actions build ${{ inputs.version }}" + - name: Sign Windows update + if: ${{ inputs.package_installer && !inputs.skip_signing && inputs.build_type != 'nightly' && inputs.sign_update_artifact }} + shell: pwsh + env: + SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }} + FULL_INSTALLER_NAME: ${{ inputs.installer_base_name }}${{ inputs.build_type != 'production' && format('-{0}', inputs.build_type) || '' }} + run: | + & ".\.github\scripts\sign_windows_update.ps1" ` + -InstallerPath "$env:FULL_INSTALLER_NAME.exe" ` + -SignaturePath "$env:RUNNER_TEMP\$env:FULL_INSTALLER_NAME.exe.sparkle-signature" + - name: Upload Windows installer + if: ${{ inputs.package_installer }} uses: actions/upload-artifact@v4 with: name: lantern-installer-exe path: ${{ inputs.installer_base_name }}${{ inputs.build_type != 'production' && format('-{0}', inputs.build_type) || '' }}.exe retention-days: 2 + + - name: Upload Windows update signature + if: ${{ inputs.package_installer && !inputs.skip_signing && inputs.build_type != 'nightly' && inputs.sign_update_artifact }} + uses: actions/upload-artifact@v4 + with: + name: lantern-installer-exe-signature + path: ${{ runner.temp }}/${{ inputs.installer_base_name }}${{ inputs.build_type != 'production' && format('-{0}', inputs.build_type) || '' }}.exe.sparkle-signature + retention-days: 2 diff --git a/.github/workflows/firebase-test-lab.yml b/.github/workflows/firebase-test-lab.yml index 898d431c64..a1d2b48b31 100644 --- a/.github/workflows/firebase-test-lab.yml +++ b/.github/workflows/firebase-test-lab.yml @@ -107,7 +107,7 @@ jobs: make android-env >> "$GITHUB_ENV" - name: Install Flutter - uses: subosito/flutter-action@v2.22.0 + uses: subosito/flutter-action@v2.23.0 with: channel: stable flutter-version-file: .github/flutter-version.yaml diff --git a/.github/workflows/flutter-test.yml b/.github/workflows/flutter-test.yml index c3dba788e2..4bc0e12c5b 100644 --- a/.github/workflows/flutter-test.yml +++ b/.github/workflows/flutter-test.yml @@ -51,7 +51,7 @@ jobs: ${{ runner.os }}-flutter- - name: Install Flutter - uses: subosito/flutter-action@v2.22.0 + uses: subosito/flutter-action@v2.23.0 with: channel: stable flutter-version-file: .github/flutter-version.yaml diff --git a/.github/workflows/macos-auto-update-smoke.yml b/.github/workflows/macos-auto-update-smoke.yml new file mode 100644 index 0000000000..00d21bea66 --- /dev/null +++ b/.github/workflows/macos-auto-update-smoke.yml @@ -0,0 +1,186 @@ +name: macOS auto-update smoke + +on: + workflow_dispatch: + workflow_call: + +permissions: + contents: read + +concurrency: + group: macos-auto-update-smoke + cancel-in-progress: false + +jobs: + resolve-target: + name: Resolve staging fixture target + runs-on: ubuntu-latest + outputs: + display_version: ${{ steps.target.outputs.display_version }} + fixture_build: ${{ steps.target.outputs.fixture_build }} + target_build: ${{ steps.target.outputs.target_build }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: scripts/requirements.txt + + - name: Install appcast parser dependency + run: python -m pip install -r scripts/requirements.txt + + - name: Test target resolver + run: python -m unittest scripts/ci/resolve_desktop_update_target_test.py + + - name: Resolve staging fixture target + id: target + shell: bash + env: + TARGET_DIR: ${{ runner.temp }}/macos-auto-update-target + run: | + set -o pipefail + mkdir -p "$TARGET_DIR/pubspec" + python scripts/ci/resolve_desktop_update_target.py \ + --platform macos \ + --appcast-url "https://update.staging.iantem.io/update/lantern/appcast.xml?channel=beta" \ + --appcast-output "$TARGET_DIR/appcast.xml" \ + --target-output "$TARGET_DIR/target.json" \ + --pubspec-input pubspec.yaml \ + --pubspec-output "$TARGET_DIR/pubspec/pubspec.yaml" \ + --github-output "$GITHUB_OUTPUT" \ + 2>&1 | tee "$TARGET_DIR/resolve.log" + + - name: Summarize target + if: ${{ steps.target.outcome == 'success' }} + shell: bash + env: + DISPLAY_VERSION: ${{ steps.target.outputs.display_version }} + FIXTURE_BUILD: ${{ steps.target.outputs.fixture_build }} + TARGET_BUILD: ${{ steps.target.outputs.target_build }} + run: | + { + echo "### macOS auto-update target" + echo + echo "- Live beta: \`$DISPLAY_VERSION\` build \`$TARGET_BUILD\`" + echo "- Unpublished fixture: \`$DISPLAY_VERSION\` build \`$FIXTURE_BUILD\`" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload target diagnostics + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: macos-auto-update-target + path: | + ${{ runner.temp }}/macos-auto-update-target/appcast.xml + ${{ runner.temp }}/macos-auto-update-target/target.json + ${{ runner.temp }}/macos-auto-update-target/resolve.log + if-no-files-found: warn + retention-days: 7 + + - name: Upload lower-build pubspec + if: ${{ steps.target.outcome == 'success' }} + uses: actions/upload-artifact@v4 + with: + name: macos-auto-update-pubspec + path: ${{ runner.temp }}/macos-auto-update-target/pubspec/pubspec.yaml + if-no-files-found: error + retention-days: 2 + + build-fixture: + name: Build signed lower fixture + needs: resolve-target + permissions: + contents: read + id-token: write + uses: ./.github/workflows/build-macos.yml + secrets: inherit + with: + version: ${{ needs.resolve-target.outputs.display_version }} + build_type: beta + installer_base_name: lantern-autoupdate-fixture + runner_label: lantern-macos-smoke + flutter_target: integration_test/auto_update/desktop_auto_update_smoke_test.dart + # flutter drive needs the VM service exposed by a profile fixture. + flutter_build_mode: profile + pubspec_artifact: macos-auto-update-pubspec + sign_update_artifact: false + auto_update_e2e: true + run_connect_smoke: false + run_payment_smoke: false + + exercise-update: + name: Install and update through Sparkle + needs: + - resolve-target + - build-fixture + runs-on: + - self-hosted + - lantern-macos-smoke + timeout-minutes: 30 + env: + LANTERN_AUTO_UPDATE_SMOKE: "true" + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Download fixture pubspec + uses: actions/download-artifact@v4 + with: + name: macos-auto-update-pubspec + + - name: Install Flutter + uses: subosito/flutter-action@v2.23.0 + with: + channel: stable + flutter-version-file: .github/flutter-version.yaml + + - name: Resolve Flutter dependencies + run: flutter pub get + + - name: Download lower fixture + uses: actions/download-artifact@v4 + with: + name: lantern-installer-dmg + path: ${{ runner.temp }}/macos-auto-update-fixture + + - name: Download resolved target + uses: actions/download-artifact@v4 + with: + name: macos-auto-update-target + path: ${{ runner.temp }}/macos-auto-update-target + + - name: Run Flutter-led auto-update smoke + shell: bash + env: + APPCAST_XML: ${{ runner.temp }}/macos-auto-update-target/appcast.xml + ARTIFACT_DIR: ${{ runner.temp }}/macos-auto-update-smoke + FIXTURE_DMG: ${{ runner.temp }}/macos-auto-update-fixture/lantern-autoupdate-fixture-beta.dmg + TARGET_JSON: ${{ runner.temp }}/macos-auto-update-target/target.json + run: bash ./.github/scripts/macos_auto_update_smoke.sh + + - name: Upload auto-update diagnostics + if: ${{ always() }} + id: diagnostics + uses: actions/upload-artifact@v4 + with: + name: macos-auto-update-smoke + path: ${{ runner.temp }}/macos-auto-update-smoke + if-no-files-found: warn + retention-days: 7 + + - name: Link diagnostics + if: ${{ always() && steps.diagnostics.outputs.artifact-url != '' }} + shell: bash + env: + ARTIFACT_URL: ${{ steps.diagnostics.outputs.artifact-url }} + run: | + echo "::notice title=macOS auto-update diagnostics::$ARTIFACT_URL" + echo "[Download macOS auto-update diagnostics]($ARTIFACT_URL)" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7b4539a89b..ce01499695 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,6 +96,7 @@ jobs: publish_mobile_stores: ${{ steps.meta.outputs.publish_mobile_stores }} smoke_enable_ip_check: ${{ steps.meta.outputs.smoke_enable_ip_check }} stealth_leakage_mode: ${{ steps.meta.outputs.stealth_leakage_mode }} + sparkle_version: ${{ steps.meta.outputs.sparkle_version }} steps: - name: Checkout repo uses: actions/checkout@v4 @@ -306,6 +307,7 @@ jobs: echo "publish_mobile_stores=$PUBLISH_MOBILE_STORES" >> $GITHUB_OUTPUT echo "smoke_enable_ip_check=$SMOKE_ENABLE_IP_CHECK" >> $GITHUB_OUTPUT echo "stealth_leakage_mode=$STEALTH_LEAKAGE_MODE" >> $GITHUB_OUTPUT + echo "sparkle_version=$GITHUB_RUN_NUMBER" >> $GITHUB_OUTPUT - name: Update pubspec.yaml version shell: bash @@ -890,25 +892,31 @@ jobs: with: ref: ${{ github.sha }} - - name: Install Flutter - uses: subosito/flutter-action@v2.22.0 - with: - channel: stable - flutter-version-file: .github/flutter-version.yaml - - name: Install Python dependencies run: python3 -m pip install -r scripts/requirements.txt - name: Test update metadata generator run: python3 -m unittest scripts/ci/generate_update_metadata_test.py - - name: Publish update metadata sidecars - # DISABLED: `auto_updater:sign_update` exits 255 on the dmg, which failed - # this job and made release-finalize delete the draft release on every - # tagged run (v9.1.19-beta, v9.1.20-beta got tags with no release). + - name: Download macOS update signature if: | - false && - needs.set-metadata.outputs.build_type != 'nightly' + needs.set-metadata.outputs.platform == 'all' || + contains(needs.set-metadata.outputs.platform, 'macos') + uses: actions/download-artifact@v4 + with: + name: lantern-installer-dmg-signature + path: update-signatures + + - name: Download Windows update signature + if: | + needs.set-metadata.outputs.platform == 'all' || + contains(needs.set-metadata.outputs.platform, 'windows') + uses: actions/download-artifact@v4 + with: + name: lantern-installer-exe-signature + path: update-signatures + + - name: Publish update metadata sidecars env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} @@ -941,8 +949,10 @@ jobs: python3 scripts/ci/generate_update_metadata.py \ --build-type "$BUILD_TYPE" \ --version "$VERSION" \ + --sparkle-version "${{ needs.set-metadata.outputs.sparkle_version }}" \ --bucket "$BUCKET" \ --output-dir update-metadata \ + --sparkle-signature-dir update-signatures \ "${artifacts[@]}" for metadata in update-metadata/*.update.json; do @@ -952,47 +962,9 @@ jobs: gh release upload "$RELEASE_TAG" "$metadata" --clobber done - - name: Publish legacy appcast.xml - if: | - needs.set-metadata.outputs.build_type != 'nightly' && - ( - needs.set-metadata.outputs.platform == 'all' || - contains(needs.set-metadata.outputs.platform, 'macos') || - contains(needs.set-metadata.outputs.platform, 'windows') || - contains(needs.set-metadata.outputs.platform, 'linux') - ) - env: - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - GITHUB_TOKEN: ${{ secrets.CI_PRIVATE_REPOS_GH_TOKEN }} - GH_TOKEN: ${{ github.token }} - BUILD_TYPE: ${{ needs.set-metadata.outputs.build_type }} - RELEASE_TAG: ${{ needs.set-metadata.outputs.release_tag }} - BUCKET: ${{ vars.S3_RELEASES_BUCKET }} - run: | - # Strip 'v' prefix for S3 paths - VERSION="${RELEASE_TAG#v}" - - python3 scripts/generate_appcast.py - - # Keep this legacy feed for released clients that still point at S3. - # New clients use lantern-cloud's channel-aware appcast endpoint. - aws s3 cp appcast.xml "s3://${BUCKET}/releases/${BUILD_TYPE}/${VERSION}/appcast.xml" --acl public-read - aws s3 cp appcast.xml "s3://${BUCKET}/releases/${BUILD_TYPE}/latest/appcast.xml" --acl public-read - if [[ "$BUILD_TYPE" == "production" ]]; then - aws s3 cp appcast.xml "s3://${BUCKET}/releases/appcast.xml" --acl public-read - fi - - # Upload appcast.xml to GitHub release, but - # not git because we may be in detached HEAD - gh release upload "$RELEASE_TAG" appcast.xml --clobber - verify-update-service: needs: [set-metadata, publish-update-metadata, release-finalize] - # DISABLED with the sidecar step above: with nothing published, this polls - # for its full 2700s timeout and then fails. if: | - false && !cancelled() && needs.set-metadata.outputs.build_type == 'beta' && needs.publish-update-metadata.result == 'success' && @@ -1000,6 +972,7 @@ jobs: uses: ./.github/workflows/verify-update-service.yml with: version: ${{ needs.set-metadata.outputs.version }} + sparkle_version: ${{ needs.set-metadata.outputs.sparkle_version }} channel: beta platform: ${{ needs.set-metadata.outputs.platform }} update_url: "https://update.getlantern.org/update/lantern" @@ -1035,15 +1008,24 @@ jobs: env.BUILD_TYPE != 'nightly' && needs.upload-s3.result == 'success' && needs.upload-release-artifacts.result == 'success' && - needs.publish-update-metadata.result == 'success' && (needs.upload-google-play.result == 'success' || needs.upload-google-play.result == 'skipped') && (needs.upload-testflight.result == 'success' || needs.upload-testflight.result == 'skipped') env: GH_TOKEN: ${{ github.token }} run: | - echo "All steps succeeded - publishing draft release" + echo "Required release artifacts succeeded - publishing draft release" gh release edit "$RELEASE_TAG" --draft=false + - name: Report update metadata failure + if: | + env.IS_TEST_RUN != 'true' && + env.BUILD_TYPE != 'nightly' && + needs.publish-update-metadata.result == 'failure' + run: | + echo "::warning title=Auto-update metadata was not published::The release remains valid, but desktop auto-update metadata needs follow-up." + echo "### Auto-update metadata publishing failed" >> "$GITHUB_STEP_SUMMARY" + echo "The release was preserved, but desktop auto-update metadata needs follow-up." >> "$GITHUB_STEP_SUMMARY" + - name: Delete draft release if: | env.IS_TEST_RUN == 'true' || @@ -1051,7 +1033,6 @@ jobs: (env.CLEANUP_ON_FAILURE == 'true' && !(needs.upload-s3.result == 'success' && needs.upload-release-artifacts.result == 'success' && - needs.publish-update-metadata.result == 'success' && (needs.upload-google-play.result == 'success' || needs.upload-google-play.result == 'skipped') && (needs.upload-testflight.result == 'success' || needs.upload-testflight.result == 'skipped'))) env: @@ -1094,7 +1075,6 @@ jobs: env.CLEANUP_ON_FAILURE != 'true' && !(needs.upload-s3.result == 'success' && needs.upload-release-artifacts.result == 'success' && - needs.publish-update-metadata.result == 'success' && (needs.upload-google-play.result == 'success' || needs.upload-google-play.result == 'skipped') && (needs.upload-testflight.result == 'success' || needs.upload-testflight.result == 'skipped')) run: | diff --git a/.github/workflows/verify-update-service.yml b/.github/workflows/verify-update-service.yml index 42f5227c46..09b885ee6d 100644 --- a/.github/workflows/verify-update-service.yml +++ b/.github/workflows/verify-update-service.yml @@ -7,6 +7,11 @@ on: description: "Release version to verify, with or without leading v" required: true type: string + sparkle_version: + description: "Desktop bundle build number to verify" + required: false + type: string + default: "" channel: description: "Release channel to verify" required: false @@ -39,6 +44,10 @@ on: version: required: true type: string + sparkle_version: + required: false + type: string + default: "" channel: required: false type: string @@ -86,6 +95,7 @@ jobs: CHANNEL: ${{ inputs.channel }} PLATFORM: ${{ inputs.platform }} VERSION: ${{ inputs.version }} + SPARKLE_VERSION: ${{ inputs.sparkle_version }} TIMEOUT_SECONDS: ${{ inputs.timeout_seconds }} INTERVAL_SECONDS: ${{ inputs.interval_seconds }} run: | @@ -94,5 +104,6 @@ jobs: --channel "$CHANNEL" \ --platform "$PLATFORM" \ --version "$VERSION" \ + --sparkle-version "$SPARKLE_VERSION" \ --timeout-seconds "$TIMEOUT_SECONDS" \ --interval-seconds "$INTERVAL_SECONDS" diff --git a/.github/workflows/windows-auto-update-smoke.yml b/.github/workflows/windows-auto-update-smoke.yml new file mode 100644 index 0000000000..a62cac5f3e --- /dev/null +++ b/.github/workflows/windows-auto-update-smoke.yml @@ -0,0 +1,201 @@ +name: Windows auto-update smoke + +on: + workflow_dispatch: + workflow_call: + +permissions: + contents: read + +concurrency: + group: windows-auto-update-smoke + cancel-in-progress: false + +jobs: + resolve-target: + name: Resolve staging fixture target + runs-on: ubuntu-latest + outputs: + display_version: ${{ steps.target.outputs.display_version }} + fixture_build: ${{ steps.target.outputs.fixture_build }} + target_build: ${{ steps.target.outputs.target_build }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: scripts/requirements.txt + + - name: Install appcast parser dependency + run: python -m pip install -r scripts/requirements.txt + + - name: Test target resolver + run: python -m unittest scripts/ci/resolve_desktop_update_target_test.py + + - name: Resolve staging fixture target + id: target + shell: bash + env: + TARGET_DIR: ${{ runner.temp }}/windows-auto-update-target + run: | + set -o pipefail + mkdir -p "$TARGET_DIR/pubspec" + python scripts/ci/resolve_desktop_update_target.py \ + --platform windows \ + --appcast-url "https://update.staging.iantem.io/update/lantern/appcast.xml?channel=beta" \ + --appcast-output "$TARGET_DIR/appcast.xml" \ + --target-output "$TARGET_DIR/target.json" \ + --pubspec-input pubspec.yaml \ + --pubspec-output "$TARGET_DIR/pubspec/pubspec.yaml" \ + --github-output "$GITHUB_OUTPUT" \ + 2>&1 | tee "$TARGET_DIR/resolve.log" + + - name: Summarize target + if: ${{ steps.target.outcome == 'success' }} + shell: bash + env: + DISPLAY_VERSION: ${{ steps.target.outputs.display_version }} + FIXTURE_BUILD: ${{ steps.target.outputs.fixture_build }} + TARGET_BUILD: ${{ steps.target.outputs.target_build }} + run: | + { + echo "### Windows auto-update target" + echo + echo "- Live beta: \`$DISPLAY_VERSION\` build \`$TARGET_BUILD\`" + echo "- Unpublished fixture: \`$DISPLAY_VERSION\` build \`$FIXTURE_BUILD\`" + } >> "$GITHUB_STEP_SUMMARY" + + - name: Upload target diagnostics + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: windows-auto-update-target + path: | + ${{ runner.temp }}/windows-auto-update-target/appcast.xml + ${{ runner.temp }}/windows-auto-update-target/target.json + ${{ runner.temp }}/windows-auto-update-target/resolve.log + if-no-files-found: warn + retention-days: 7 + + - name: Upload lower-build pubspec + if: ${{ steps.target.outcome == 'success' }} + uses: actions/upload-artifact@v4 + with: + name: windows-auto-update-pubspec + path: ${{ runner.temp }}/windows-auto-update-target/pubspec/pubspec.yaml + if-no-files-found: error + retention-days: 2 + + build-fixture: + name: Build signed lower fixture + needs: resolve-target + permissions: + contents: read + id-token: write + uses: ./.github/workflows/build-windows.yml + secrets: inherit + with: + version: ${{ needs.resolve-target.outputs.display_version }} + build_type: beta + installer_base_name: lantern-autoupdate-fixture + flutter_target: integration_test/auto_update/desktop_auto_update_smoke_test.dart + # flutter drive needs the VM service exposed by a profile fixture. + flutter_build_mode: profile + pubspec_artifact: windows-auto-update-pubspec + sign_update_artifact: false + auto_update_e2e: true + # Exercise WinSparkle's target installer without rebuilding the signed + # profile fixture through Fastforge's installer packaging step. + package_installer: false + run_installer_smoke: false + run_connect_smoke: false + run_split_tunnel_website_smoke: false + run_config_url_smoke: false + run_auth_smoke: false + run_payment_smoke: false + + exercise-update: + name: Install and update through WinSparkle + needs: + - resolve-target + - build-fixture + runs-on: windows-latest + timeout-minutes: 30 + env: + LANTERN_AUTO_UPDATE_SMOKE: "true" + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + persist-credentials: false + + - name: Download fixture pubspec + uses: actions/download-artifact@v4 + with: + name: windows-auto-update-pubspec + + - name: Read pinned Flutter version + id: flutter-version + shell: pwsh + run: | + $match = Select-String -Path '.github/flutter-version.yaml' -Pattern '^\s*flutter:\s*["'']?([^"'']+)["'']?\s*$' + if (-not $match) { + throw 'Unable to read the pinned Flutter version' + } + "version=$($match.Matches[0].Groups[1].Value.Trim())" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 + + - name: Install Flutter + uses: subosito/flutter-action@v2.23.0 + with: + channel: stable + flutter-version: ${{ steps.flutter-version.outputs.version }} + + - name: Resolve Flutter dependencies + run: flutter pub get + + - name: Download signed lower fixture + uses: actions/download-artifact@v4 + with: + name: lantern-windows-app-fixture + path: ${{ runner.temp }}/windows-auto-update-fixture + + - name: Download resolved target + uses: actions/download-artifact@v4 + with: + name: windows-auto-update-target + path: ${{ runner.temp }}/windows-auto-update-target + + - name: Run Flutter-led auto-update smoke + shell: pwsh + env: + APPCAST_XML: ${{ runner.temp }}/windows-auto-update-target/appcast.xml + ARTIFACT_DIR: ${{ runner.temp }}/windows-auto-update-smoke + FIXTURE_APP_DIR: ${{ runner.temp }}/windows-auto-update-fixture + TARGET_JSON: ${{ runner.temp }}/windows-auto-update-target/target.json + run: ./.github/scripts/windows_auto_update_smoke.ps1 + + - name: Upload auto-update diagnostics + if: ${{ always() }} + id: diagnostics + uses: actions/upload-artifact@v4 + with: + name: windows-auto-update-smoke + path: ${{ runner.temp }}/windows-auto-update-smoke + if-no-files-found: warn + retention-days: 7 + + - name: Link diagnostics + if: ${{ always() && steps.diagnostics.outputs.artifact-url != '' }} + shell: pwsh + env: + ARTIFACT_URL: ${{ steps.diagnostics.outputs.artifact-url }} + run: | + Write-Host "::notice title=Windows auto-update diagnostics::$env:ARTIFACT_URL" + "[Download Windows auto-update diagnostics]($env:ARTIFACT_URL)" | + Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 diff --git a/Makefile b/Makefile index 5dc47cb626..9fd8f77d81 100644 --- a/Makefile +++ b/Makefile @@ -102,6 +102,8 @@ DARWIN_LIB_BUILD := $(BIN_DIR)/macos/$(DARWIN_LIB) DARWIN_RELEASE_DIR := $(BUILD_DIR)/macos/Build/Products/Release DARWIN_DEBUG_BUILD := $(BUILD_DIR)/macos/Build/Products/Debug/$(DARWIN_APP_NAME) DARWIN_RELEASE_BUILD := $(DARWIN_RELEASE_DIR)/$(DARWIN_APP_NAME) +DARWIN_PROFILE_DIR := $(BUILD_DIR)/macos/Build/Products/Profile +DARWIN_PROFILE_BUILD := $(DARWIN_PROFILE_DIR)/$(DARWIN_APP_NAME) MACOS_ENTITLEMENTS := macos/Runner/Release.entitlements MACOS_INSTALLER := $(INSTALLER_NAME)$(if $(filter-out production,$(BUILD_TYPE)),-$(BUILD_TYPE)).dmg MACOS_DIR := macos/ @@ -156,7 +158,10 @@ WINDOWS_LIB_AMD64 := $(BIN_DIR)/windows-amd64/$(WINDOWS_LIB) WINDOWS_LIB_ARM64 := $(BIN_DIR)/windows-arm64/$(WINDOWS_LIB) WINDOWS_LIB_BUILD := $(BIN_DIR)/windows/$(WINDOWS_LIB) WINDOWS_DEBUG_DIR := $(BUILD_DIR)/windows/x64/runner/Debug +WINDOWS_PROFILE_DIR := $(BUILD_DIR)/windows/x64/runner/Profile WINDOWS_RELEASE_DIR := $(BUILD_DIR)/windows/x64/runner/Release +LANTERND_WINDOWS_PROFILE := $(WINDOWS_PROFILE_DIR)/$(LANTERND).exe +LANTERND_WINDOWS_PROFILE_ARM64 := $(WINDOWS_PROFILE_DIR)/arm64/$(LANTERND).exe LANTERND_WINDOWS_RELEASE := $(WINDOWS_RELEASE_DIR)/$(LANTERND).exe LANTERND_WINDOWS_RELEASE_ARM64 := $(WINDOWS_RELEASE_DIR)/arm64/$(LANTERND).exe @@ -268,7 +273,9 @@ SIGN_ID="Developer ID Application: Brave New Software Project, Inc (ACZRKC3LQ9)" get-command = $(shell which="$$(which $(1) 2> /dev/null)" && if [[ ! -z "$$which" ]]; then printf %q "$$which"; fi) APPDMG := $(call get-command,appdmg) -DART_DEFINES := --dart-define=BUILD_TYPE=$(BUILD_TYPE) $(if $(VERSION),--dart-define=VERSION=$(VERSION),) +AUTO_UPDATE_E2E_DART_DEFINE := $(if $(filter true 1 yes,$(AUTO_UPDATE_E2E)),--dart-define=AUTO_UPDATE_E2E=true,) +DART_DEFINES := --dart-define=BUILD_TYPE=$(BUILD_TYPE) $(if $(VERSION),--dart-define=VERSION=$(VERSION),) $(AUTO_UPDATE_E2E_DART_DEFINE) +FLUTTER_TARGET_ARG := $(if $(FLUTTER_TARGET),--target=$(FLUTTER_TARGET),) STEALTH_NOVPN_BUILD_VARS := BUILD_TYPE=stealth-novpn STEALTH_MODE=stealth-novpn STEALTH_LEAKAGE_MODE=stealth-novpn STEALTH_VPN_BUILD_VARS := BUILD_TYPE=stealth-vpn STEALTH_MODE=stealth-vpn STEALTH_LEAKAGE_MODE=stealth-vpn STEALTH_ICON_SEED ?= @@ -522,10 +529,26 @@ macos-unit-tests: $(MACOS_FRAMEWORK_OUTPUT) $(MAYBE_STEALTH_PROFILE) $(DARWIN_RELEASE_BUILD): $(MAYBE_STEALTH_PROFILE) @echo "Building Flutter app (release) for macOS..." rm -vf $(MACOS_INSTALLER) - flutter build macos --release $(DART_DEFINES) $(STEALTH_DART_DEFINES) + flutter build macos --release $(FLUTTER_TARGET_ARG) $(DART_DEFINES) $(STEALTH_DART_DEFINES) build-macos-release: $(DARWIN_RELEASE_BUILD) +$(DARWIN_PROFILE_BUILD): $(MAYBE_STEALTH_PROFILE) + @echo "Building Flutter app (profile) for macOS..." + rm -vf $(MACOS_INSTALLER) + flutter build macos --profile $(FLUTTER_TARGET_ARG) $(DART_DEFINES) $(STEALTH_DART_DEFINES) + +.PHONY: build-macos-release build-macos-profile stage-macos-profile +build-macos-profile: $(DARWIN_PROFILE_BUILD) + +# Keep packaging and signing on the established Release staging path. The +# profile fixture is test-only, but it still goes through the same Developer ID +# signing, DMG packaging, and notarization steps as a release artifact. +stage-macos-profile: build-macos-profile + rm -rf $(DARWIN_RELEASE_BUILD) + mkdir -p $(DARWIN_RELEASE_DIR) + ditto $(DARWIN_PROFILE_BUILD) $(DARWIN_RELEASE_BUILD) + .PHONY: notarize-darwin notarize-darwin: require-ac-username require-ac-password @echo "Notarizing distribution package..." @@ -573,6 +596,9 @@ macos-release: clean macos pubget gen build-macos-release sign-app package-macos .PHONY: macos-release-ci macos-release-ci: macos pubget gen build-macos-release sign-app package-macos notarize-darwin +.PHONY: macos-profile-ci +macos-profile-ci: macos pubget gen stage-macos-profile sign-app package-macos notarize-darwin + # Linux Build .PHONY: install-linux-deps @@ -738,12 +764,27 @@ copy-lanternd-debug: $(LANTERND_WINDOWS_AMD64) $(call MKDIR_P,$(WINDOWS_DEBUG_DIR)) $(call COPY_FILE,$(LANTERND_WINDOWS_AMD64),$(WINDOWS_DEBUG_DIR)/$(LANTERND).exe) +.PHONY: copy-lanternd-profile copy-lanternd-profile-arm64 +copy-lanternd-profile: $(LANTERND_WINDOWS_AMD64) + $(call MKDIR_P,$(WINDOWS_PROFILE_DIR)) + $(call COPY_FILE,$(LANTERND_WINDOWS_AMD64),$(LANTERND_WINDOWS_PROFILE)) + +copy-lanternd-profile-arm64: $(LANTERND_WINDOWS_ARM64) + $(call MKDIR_P,$(dir $(LANTERND_WINDOWS_PROFILE_ARM64))) + $(call COPY_FILE,$(LANTERND_WINDOWS_ARM64),$(LANTERND_WINDOWS_PROFILE_ARM64)) + .PHONY: prepare-windows-release prepare-windows-release: lanternd-windows-amd64 lanternd-windows-arm64 $(MAKE) copy-lanternd-release $(MAKE) copy-lanternd-release-arm64 $(call WRITE_TEXT_FILE,$(LANTERND_SERVICE_LOG_LEVEL),$(WINDOWS_RELEASE_DIR)/lanternd-log-level) +.PHONY: prepare-windows-profile +prepare-windows-profile: lanternd-windows-amd64 lanternd-windows-arm64 + $(MAKE) copy-lanternd-profile + $(MAKE) copy-lanternd-profile-arm64 + $(call WRITE_TEXT_FILE,$(LANTERND_SERVICE_LOG_LEVEL),$(WINDOWS_PROFILE_DIR)/lanternd-log-level) + .PHONY: windows-debug windows-debug: windows $(MAYBE_STEALTH_PROFILE) @echo "Building Flutter app (debug) for Windows..." @@ -752,11 +793,24 @@ windows-debug: windows $(MAYBE_STEALTH_PROFILE) .PHONY: build-windows-release build-windows-release: $(MAYBE_STEALTH_PROFILE) @echo "Building Flutter app (release) for Windows..." - flutter build windows --release --verbose $(DART_DEFINES) $(STEALTH_DART_DEFINES) + flutter build windows --release --verbose $(FLUTTER_TARGET_ARG) $(DART_DEFINES) $(STEALTH_DART_DEFINES) + +.PHONY: build-windows-profile stage-windows-profile +build-windows-profile: $(MAYBE_STEALTH_PROFILE) + @echo "Building Flutter app (profile) for Windows..." + flutter build windows --profile --verbose $(FLUTTER_TARGET_ARG) $(DART_DEFINES) $(STEALTH_DART_DEFINES) + +# Fastforge packages the Release directory. Stage the test-only profile build +# there after its native service binaries have been added. +stage-windows-profile: build-windows-profile prepare-windows-profile + $(PS) "if (Test-Path '$(WINDOWS_RELEASE_DIR)') { Remove-Item -Recurse -Force -LiteralPath '$(WINDOWS_RELEASE_DIR)' }; New-Item -ItemType Directory -Force -Path '$(WINDOWS_RELEASE_DIR)' | Out-Null; Copy-Item -Recurse -Force -Path '$(WINDOWS_PROFILE_DIR)/*' -Destination '$(WINDOWS_RELEASE_DIR)'" .PHONY: windows-release windows-release: clean windows pubget gen build-windows-release prepare-windows-release +.PHONY: windows-profile-ci +windows-profile-ci: clean windows pubget gen stage-windows-profile + .PHONY: install-gomobile install-gomobile: GOTOOLCHAIN=$(GO_VERSION) go install -v golang.org/x/mobile/cmd/gomobile@$(GOMOBILE_VERSION) diff --git a/integration_test/auto_update/auto_update_robot.dart b/integration_test/auto_update/auto_update_robot.dart new file mode 100644 index 0000000000..9c7ac38c9f --- /dev/null +++ b/integration_test/auto_update/auto_update_robot.dart @@ -0,0 +1,66 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import '../utils/app_robot.dart'; +import '../utils/widget_wait_utils.dart'; + +String get autoUpdateHandoffPath { + if (Platform.isMacOS) { + return '/Users/Shared/Lantern/E2E/auto-update-handoff.json'; + } + if (Platform.isWindows) { + final programData = Platform.environment['ProgramData']; + if (programData == null || programData.isEmpty) { + throw StateError('ProgramData is unavailable for the update handoff'); + } + return '$programData\\Lantern\\E2E\\auto-update-handoff.json'; + } + throw UnsupportedError('Auto-update handoff is desktop-only'); +} + +/// Drives Lantern up to the native Sparkle boundary. +class AutoUpdateRobot { + AutoUpdateRobot(this.tester) : app = AppRobot(tester); + + final WidgetTester tester; + final AppRobot app; + + final Finder checkForUpdates = find.byKey( + const Key('setting.check_for_updates_tile'), + ); + + Future triggerUpdateCheck() async { + e2eLog('Opening Settings for the auto-update smoke'); + await app.openSettings(); + await WidgetWaitUtils.waitForFinder( + tester, + checkForUpdates, + timeout: const Duration(seconds: 30), + reason: + 'Check for Updates did not appear. ' + 'Visible keys: ${app.visibleKeys().join(', ')}', + ); + await tester.ensureVisible(checkForUpdates); + await app.captureScreenshot('auto-update-before'); + + e2eLog('Triggering Check for Updates'); + await app.tap(checkForUpdates, name: 'Check for Updates', settle: false); + } + + Future writeNativeHandoff() async { + final packageInfo = await PackageInfo.fromPlatform(); + final handoff = File(autoUpdateHandoffPath); + final payload = { + 'pid': pid, + 'display_version': packageInfo.version, + 'build_number': packageInfo.buildNumber, + 'created_at': DateTime.now().toUtc().toIso8601String(), + }; + await handoff.parent.create(recursive: true); + await handoff.writeAsString('${jsonEncode(payload)}\n', flush: true); + e2eLog('Native updater handoff ready at ${handoff.path}'); + } +} diff --git a/integration_test/auto_update/desktop_auto_update_smoke_test.dart b/integration_test/auto_update/desktop_auto_update_smoke_test.dart new file mode 100644 index 0000000000..bb05ab6cb9 --- /dev/null +++ b/integration_test/auto_update/desktop_auto_update_smoke_test.dart @@ -0,0 +1,41 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:lantern/core/common/app_build_info.dart'; +import 'package:lantern/main.dart' as app; + +import 'auto_update_robot.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets( + 'lower signed beta fixture hands off the native update prompt', + (tester) async { + expect( + Platform.isMacOS || Platform.isWindows, + isTrue, + reason: 'This smoke is desktop-only', + ); + expect(kProfileMode, isTrue, reason: 'The fixture must be a profile app'); + expect( + AppBuildInfo.autoUpdateE2E, + isTrue, + reason: 'The fixture must use the isolated staging appcast', + ); + expect( + AppBuildInfo.buildType, + 'beta', + reason: 'The fixture must use the beta update channel', + ); + + await app.main(); + final robot = AutoUpdateRobot(tester); + await robot.triggerUpdateCheck(); + await robot.writeNativeHandoff(); + }, + timeout: const Timeout(Duration(minutes: 3)), + ); +} diff --git a/integration_test/utils/app_robot.dart b/integration_test/utils/app_robot.dart index 4b60a0609d..57210c8dc9 100644 --- a/integration_test/utils/app_robot.dart +++ b/integration_test/utils/app_robot.dart @@ -1,7 +1,12 @@ +import 'dart:io'; +import 'dart:ui' as ui; + import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:lantern/core/common/common.dart' show appRouter; +import 'package:lantern/core/utils/storage_utils.dart'; import 'widget_wait_utils.dart'; @@ -39,6 +44,38 @@ class AppRobot { return keys.toList()..sort(); } + /// Captures the current Flutter surface without failing the smoke when the + /// platform does not expose a capturable root layer. + Future captureScreenshot(String name) async { + try { + final renderView = tester.binding.renderViews.first; + final layer = renderView.debugLayer; + if (layer is! OffsetLayer) { + e2eLog('Screenshot $name skipped: root layer is not capturable'); + return; + } + final image = await layer.toImage(renderView.paintBounds); + try { + final data = await image.toByteData(format: ui.ImageByteFormat.png); + if (data == null) { + e2eLog('Screenshot $name skipped: no image data'); + return; + } + final directory = Directory( + '${await AppStorageUtils.getAppLogDirectory()}/screenshots', + ); + await directory.create(recursive: true); + final file = File('${directory.path}/$name.png'); + await file.writeAsBytes(data.buffer.asUint8List(), flush: true); + e2eLog('Screenshot saved: ${file.path}'); + } finally { + image.dispose(); + } + } catch (error) { + e2eLog('Screenshot $name failed: $error'); + } + } + /// Waits until home is usable. Onboarding is pushed on top shortly after /// home renders, so gate on the menu button via [waitForControlReady], /// which waits out that window and clears onboarding. @@ -55,7 +92,7 @@ class AppRobot { /// Opens Settings through the UI: home menu button. Future openSettings() async { await waitForHomeReady(); - await _tap( + await tap( find.byKey(const Key('home.menu_button')), name: 'Home menu button', ); @@ -64,7 +101,7 @@ class AppRobot { /// Opens the Language screen through the UI: Settings -> Language. Future openLanguage() async { await openSettings(); - await _tap( + await tap( find.byKey(const Key('setting.language_tile')), name: 'Settings language tile', ); @@ -81,7 +118,7 @@ class AppRobot { /// same list. Future openAppearance() async { await openSettings(); - await _tap( + await tap( find.byKey(const Key('setting.appearance_tile')), name: 'Settings appearance tile', ); @@ -103,7 +140,7 @@ class AppRobot { e2eLog('No upgrade button on Settings — account is Pro'); return false; } - await _tap(upgrade, name: 'Upgrade to Pro button'); + await tap(upgrade, name: 'Upgrade to Pro button'); await WidgetWaitUtils.waitForFinder( tester, find.byKey(const Key('plans.list')), @@ -118,11 +155,11 @@ class AppRobot { /// home menu -> Settings -> Support -> Report an issue. Future openReportIssue() async { await openSettings(); - await _tap( + await tap( find.byKey(const Key('setting.support_tile')), name: 'Settings support tile', ); - await _tap( + await tap( find.byKey(const Key('support.report_issue_tile')), name: 'Support report-issue tile', ); @@ -183,8 +220,12 @@ class AppRobot { reason: 'None of ${finders.keys.join(', ')} appeared within $timeout', ); - /// Waits for [target], taps it, and lets the navigation settle. - Future _tap(Finder target, {required String name}) async { + /// Waits for [target] and taps it. Native handoffs can skip settling. + Future tap( + Finder target, { + required String name, + bool settle = true, + }) async { e2eLog('Tapping $name'); await WidgetWaitUtils.waitForFinder( tester, @@ -194,7 +235,11 @@ class AppRobot { ); await tester.ensureVisible(target); await tester.tap(target); - await tester.pumpAndSettle(); + if (settle) { + await tester.pumpAndSettle(); + } else { + await tester.pump(const Duration(milliseconds: 300)); + } } /// Waits for the home screen after launch. Does not touch onboarding. diff --git a/lib/core/common/app_build_info.dart b/lib/core/common/app_build_info.dart index 84f0daf471..81a971e141 100644 --- a/lib/core/common/app_build_info.dart +++ b/lib/core/common/app_build_info.dart @@ -17,6 +17,11 @@ class AppBuildInfo { defaultValue: false, ); + static const bool autoUpdateE2E = bool.fromEnvironment( + 'AUTO_UPDATE_E2E', + defaultValue: false, + ); + static const String stealthMode = String.fromEnvironment( 'STEALTH_MODE', defaultValue: 'normal', diff --git a/lib/core/common/app_urls.dart b/lib/core/common/app_urls.dart index 0ec820587e..b367d6965d 100644 --- a/lib/core/common/app_urls.dart +++ b/lib/core/common/app_urls.dart @@ -1,3 +1,5 @@ +import 'package:lantern/core/common/app_build_info.dart'; + class AppUrls { static String lanternOfficial = 'https://lantern.io'; static String support = 'https://support.lantern.io'; @@ -26,13 +28,19 @@ class AppUrls { 'https://update.getlantern.org/update/lantern'; static const appcastProd = '$updateServiceLantern/appcast.xml?channel=stable'; static const appcastBeta = '$updateServiceLantern/appcast.xml?channel=beta'; + static const appcastE2E = + 'https://update.staging.iantem.io/update/lantern/appcast.xml?channel=beta'; static String manuallyServerSetupURL = 'https://github.com/getlantern/lantern-server-manager'; static String digitalOceanBillingUrl = 'https://cloud.digitalocean.com/account/billing'; static const androidSideloadUpdateEndpoint = updateServiceLantern; - static String appcastFor(String buildType) { + static String appcastFor( + String buildType, { + bool autoUpdateE2E = AppBuildInfo.autoUpdateE2E, + }) { + if (autoUpdateE2E) return appcastE2E; switch (buildType) { case 'production': return appcastProd; diff --git a/lib/core/updater/updater.dart b/lib/core/updater/updater.dart index d65d4782a5..d7e15530d9 100644 --- a/lib/core/updater/updater.dart +++ b/lib/core/updater/updater.dart @@ -8,26 +8,41 @@ import 'package:lantern/core/common/common.dart'; import 'package:lantern/core/models/feature_flags.dart'; import 'package:lantern/core/services/injection_container.dart'; import 'package:lantern/core/updater/android_sideload_updater.dart'; +import 'package:lantern/core/updater/winsparkle_build_version.dart'; import 'package:lantern/lantern/lantern_service.dart'; - -class Updater { - Updater({AndroidSideloadUpdater? androidSideloadUpdater}) - : _androidSideloadUpdater = - androidSideloadUpdater ?? AndroidSideloadUpdater(); +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:tray_manager/tray_manager.dart'; +import 'package:window_manager/window_manager.dart'; + +class Updater with UpdaterListener { + Updater({ + AndroidSideloadUpdater? androidSideloadUpdater, + AutoUpdater? autoUpdater, + bool? isWindows, + Future Function()? quitForUpdate, + }) : _androidSideloadUpdater = + androidSideloadUpdater ?? AndroidSideloadUpdater(), + _autoUpdater = autoUpdater ?? AutoUpdater.instance, + _isWindowsPlatform = isWindows ?? (!kIsWeb && Platform.isWindows), + _quitForUpdate = quitForUpdate; final AndroidSideloadUpdater _androidSideloadUpdater; + final AutoUpdater _autoUpdater; + final bool _isWindowsPlatform; + final Future Function()? _quitForUpdate; - bool _initialized = false; + Future? _initialization; + bool _listenerRegistered = false; + bool _quittingForUpdate = false; bool get _isAndroidPlatform => !kIsWeb && Platform.isAndroid; bool get _isSupportedPlatform => !kIsWeb && (Platform.isMacOS || Platform.isWindows || Platform.isAndroid); - Future init() async { - if (_initialized) return; - _initialized = true; + Future init() => _initialization ??= _initialize(); + Future _initialize() async { if (kDebugMode || !_isSupportedPlatform) return; final flags = await _featureFlags(); @@ -41,6 +56,7 @@ class Updater { Future canCheckForUpdates() async { if (!_isSupportedPlatform) return false; try { + await init(); final flags = await _featureFlags(); if (_isAndroidPlatform) { return _androidSideloadUpdater.isEnabled(flags, logDisabled: false); @@ -61,16 +77,27 @@ class Updater { try { final buildType = AppBuildInfo.buildType; final feedUrl = AppUrls.appcastFor(buildType); - final updater = AutoUpdater.instance; - await updater.setFeedURL(feedUrl); - await updater.setScheduledCheckInterval(3600); + if (!_listenerRegistered) { + _autoUpdater.addListener(this); + _listenerRegistered = true; + } + if (Platform.isWindows) { + try { + final packageInfo = await PackageInfo.fromPlatform(); + setWinSparkleBuildVersion(packageInfo.buildNumber); + } catch (e, st) { + appLogger.warning('Failed to set WinSparkle build version', e, st); + } + } + await _autoUpdater.setFeedURL(feedUrl); + await _autoUpdater.setScheduledCheckInterval(3600); // Background check after startup (avoid modal immediately on launch) const firstPromptDelay = Duration(seconds: 45); unawaited( Future.delayed(firstPromptDelay, () async { try { - await updater.checkForUpdates(inBackground: true); + await _autoUpdater.checkForUpdates(inBackground: true); } catch (e, st) { appLogger.error('Failed to check for auto-updates', e, st); } @@ -87,6 +114,7 @@ class Updater { Future checkNow() async { if (!_isSupportedPlatform) return; + await init(); final flags = await _featureFlags(); if (_isAndroidPlatform) { @@ -103,9 +131,68 @@ class Updater { ); return; } - await AutoUpdater.instance.checkForUpdates(); + await _autoUpdater.checkForUpdates(); + } + + @override + void onUpdaterBeforeQuitForUpdate(AppcastItem? appcastItem) { + if (!_isWindowsPlatform || _quittingForUpdate) return; + _quittingForUpdate = true; + appLogger.info('WinSparkle is ready to install; shutting down Lantern'); + unawaited(_shutdownForWindowsUpdate()); } + Future _shutdownForWindowsUpdate() async { + try { + await (_quitForUpdate ?? _quitDesktopForUpdate)(); + } catch (e, st) { + _quittingForUpdate = false; + appLogger.error('Failed to shut down for Windows update', e, st); + } + } + + Future _quitDesktopForUpdate() async { + // WinSparkle has already launched the installer when it sends this event. + // Tear down Lantern's desktop UI so the installer can replace the binary. + try { + await windowManager.setPreventClose(false); + } catch (e, st) { + appLogger.warning('Failed to release the Lantern window', e, st); + } + try { + await trayManager.destroy(); + } catch (e, st) { + appLogger.warning('Failed to close the Lantern tray icon', e, st); + } + try { + await windowManager.destroy(); + } catch (e, st) { + appLogger.warning('Failed to close the Lantern window', e, st); + } + exit(0); + } + + @override + void onUpdaterCheckingForUpdate(Appcast? appcast) {} + + @override + void onUpdaterError(UpdaterError? error) { + appLogger.warning('Desktop update check failed: $error'); + } + + @override + void onUpdaterUpdateAvailable(AppcastItem? appcastItem) { + appLogger.info('Desktop update available'); + } + + @override + void onUpdaterUpdateDownloaded(AppcastItem? appcastItem) { + appLogger.info('Desktop update downloaded'); + } + + @override + void onUpdaterUpdateNotAvailable(UpdaterError? error) {} + Future> _featureFlags() async { final flagResult = await sl().featureFlag(); return flagResult.fold((_) => {}, (jsonStr) { diff --git a/lib/core/updater/winsparkle_build_version.dart b/lib/core/updater/winsparkle_build_version.dart new file mode 100644 index 0000000000..358bd2eb34 --- /dev/null +++ b/lib/core/updater/winsparkle_build_version.dart @@ -0,0 +1,34 @@ +import 'dart:ffi'; + +import 'package:ffi/ffi.dart'; + +typedef _SetAppBuildVersionNative = Void Function(Pointer buildVersion); +typedef _SetAppBuildVersionDart = void Function(Pointer buildVersion); + +/// Tells WinSparkle which internal build number to compare with the appcast. +/// +/// The plugin otherwise compares Lantern's display version (for example, +/// `9.1.20`) while Sparkle on macOS compares the numeric bundle build. Release +/// CI uses that shared build number for both desktop platforms. +void setWinSparkleBuildVersion(String buildVersion) { + final normalized = buildVersion.trim(); + if (normalized.isEmpty) { + throw ArgumentError.value( + buildVersion, + 'buildVersion', + 'must not be empty', + ); + } + + final winSparkle = DynamicLibrary.open('WinSparkle.dll'); + final setBuildVersion = winSparkle + .lookupFunction<_SetAppBuildVersionNative, _SetAppBuildVersionDart>( + 'win_sparkle_set_app_build_version', + ); + final nativeBuildVersion = normalized.toNativeUtf16(); + try { + setBuildVersion(nativeBuildVersion); + } finally { + calloc.free(nativeBuildVersion); + } +} diff --git a/lib/features/home/home.dart b/lib/features/home/home.dart index 786b6bbb93..46f3733e3b 100644 --- a/lib/features/home/home.dart +++ b/lib/features/home/home.dart @@ -217,6 +217,7 @@ class Home extends HookConsumerWidget { elevation: 5, leading: IconButton( key: const Key('home.menu_button'), + tooltip: 'settings'.i18n, onPressed: () { appRouter.push(Setting()); }, diff --git a/lib/features/setting/setting.dart b/lib/features/setting/setting.dart index 16b0c694d9..bfe5321a63 100644 --- a/lib/features/setting/setting.dart +++ b/lib/features/setting/setting.dart @@ -211,7 +211,9 @@ class _SettingState extends ConsumerState children: [ DividerSpace(), AppTile( + tileKey: const Key('setting.check_for_updates_tile'), label: 'check_for_updates'.i18n, + semanticsLabel: 'check_for_updates'.i18n, icon: AppImagePaths.update, onPressed: () async => await settingMenuTap( _SettingType.checkForUpdates, diff --git a/pubspec.lock b/pubspec.lock index 5b496db004..68c262e0bf 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -178,13 +178,14 @@ packages: source: hosted version: "1.0.0" auto_updater_windows: - dependency: transitive + dependency: "direct overridden" description: - name: auto_updater_windows - sha256: "2bba20a71eee072f49b7267fedd5c4f1406c4b1b1e5b83932c634dbab75b80c9" - url: "https://pub.dev" - source: hosted - version: "1.0.0" + path: "packages/auto_updater_windows" + ref: "7d8e67bcd78a8d57516775ea5cf18f4b83786afa" + resolved-ref: "7d8e67bcd78a8d57516775ea5cf18f4b83786afa" + url: "https://github.com/getlantern/auto_updater.git" + source: git + version: "1.0.1" back_button_interceptor: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 1cdd9988ab..4478a540bf 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,6 +23,11 @@ environment: flutter: ">=3.41.0 <4.0.0" dependency_overrides: + auto_updater_windows: + git: + url: https://github.com/getlantern/auto_updater.git + path: packages/auto_updater_windows + ref: 7d8e67bcd78a8d57516775ea5cf18f4b83786afa # 12.6.x bumps the native Stripe SDKs (iOS ~>25.9), keep in step with flutter_stripe 12.4.0 stripe_android: 12.4.0 stripe_ios: 12.4.0 diff --git a/scripts/ci/generate_update_metadata.py b/scripts/ci/generate_update_metadata.py index c9043f0bc4..d3b40f66ee 100755 --- a/scripts/ci/generate_update_metadata.py +++ b/scripts/ci/generate_update_metadata.py @@ -4,17 +4,15 @@ from __future__ import annotations import argparse +import base64 +import binascii import hashlib import json -import re -import subprocess +import urllib.parse from pathlib import Path from typing import Optional, Tuple -SPARKLE_SIGNATURE_RE = re.compile(r'sparkle:edSignature="([^"]+)"') - - def release_channel(build_type: str) -> str: normalized = build_type.strip().lower() if normalized in ("production", "prod", "stable", ""): @@ -50,29 +48,43 @@ def sha256_file(path: Path) -> str: return digest.hexdigest() -def sparkle_signature(path: Path) -> str: - # Lantern CI owns Sparkle signing. lantern-cloud only reads this value back - # from the sidecar when it renders the channel appcast. - proc = subprocess.run( - ["dart", "run", "auto_updater:sign_update", str(path)], - check=True, - capture_output=True, - text=True, - ) - match = SPARKLE_SIGNATURE_RE.search(proc.stdout.strip()) - if not match: - raise RuntimeError(f"could not parse Sparkle signature for {path.name}: {proc.stdout}") - return match.group(1) - - -def sidecar_for(path: Path, build_type: str, version: str, bucket: str) -> Optional[dict[str, object]]: +def updater_signature(path: Path, signature_dir: Path) -> str: + # Signing happens on the native platform build runners. This Linux job only + # validates and packages their public signatures into release sidecars. + signature_path = signature_dir / f"{path.name}.sparkle-signature" + try: + signature = signature_path.read_text(encoding="ascii").strip() + except FileNotFoundError as err: + raise RuntimeError(f"missing EdDSA signature for {path.name}") from err + + try: + decoded = base64.b64decode(signature, validate=True) + except (binascii.Error, ValueError) as err: + raise RuntimeError(f"invalid EdDSA signature for {path.name}") from err + if len(decoded) != 64: + raise RuntimeError(f"invalid EdDSA signature length for {path.name}") + return signature + + +def sidecar_for( + path: Path, + build_type: str, + version: str, + bucket: str, + signature_dir: Optional[Path] = None, + sparkle_version: Optional[str] = None, + asset_base_url: Optional[str] = None, +) -> Optional[dict[str, object]]: info = artifact_info(path.name) if info is None: return None platform, os_name, arch = info channel = release_channel(build_type) - url = f"https://s3.amazonaws.com/{bucket}/releases/{build_type}/{version}/{path.name}" + if asset_base_url: + url = f"{asset_base_url.rstrip('/')}/{urllib.parse.quote(path.name)}" + else: + url = f"https://s3.amazonaws.com/{bucket}/releases/{build_type}/{version}/{path.name}" metadata: dict[str, object] = { "schema_version": 1, "app": "lantern", @@ -89,7 +101,12 @@ def sidecar_for(path: Path, build_type: str, version: str, bucket: str) -> Optio "sha256": sha256_file(path), } if platform in ("macos", "windows"): - metadata["sparkle_ed_signature"] = sparkle_signature(path) + if not sparkle_version: + raise RuntimeError(f"missing Sparkle version for {path.name}") + if signature_dir is None: + raise RuntimeError(f"missing signature directory for {path.name}") + metadata["sparkle_version"] = sparkle_version + metadata["sparkle_ed_signature"] = updater_signature(path, signature_dir) return metadata @@ -97,8 +114,14 @@ def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--build-type", required=True) parser.add_argument("--version", required=True, help="Release version without leading v") + parser.add_argument("--sparkle-version", required=True, help="Desktop bundle build number") parser.add_argument("--bucket", required=True) parser.add_argument("--output-dir", required=True, type=Path) + parser.add_argument("--sparkle-signature-dir", required=True, type=Path) + parser.add_argument( + "--asset-base-url", + help="Override the artifact directory URL for synthetic fixture releases", + ) parser.add_argument("artifacts", nargs="+", type=Path) args = parser.parse_args() @@ -107,7 +130,15 @@ def main() -> None: for artifact in args.artifacts: if not artifact.is_file(): continue - metadata = sidecar_for(artifact, args.build_type, args.version, args.bucket) + metadata = sidecar_for( + artifact, + args.build_type, + args.version, + args.bucket, + args.sparkle_signature_dir, + args.sparkle_version, + args.asset_base_url, + ) if metadata is None: continue output = args.output_dir / f"{artifact.name}.update.json" diff --git a/scripts/ci/generate_update_metadata_test.py b/scripts/ci/generate_update_metadata_test.py index 0a09be3a37..e97f9277a4 100644 --- a/scripts/ci/generate_update_metadata_test.py +++ b/scripts/ci/generate_update_metadata_test.py @@ -2,12 +2,12 @@ from __future__ import annotations +import base64 import hashlib import pathlib -import subprocess import sys import tempfile -from unittest import TestCase, main, mock +from unittest import TestCase, main sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) @@ -127,53 +127,101 @@ def test_sidecar_for_skips_unknown_artifacts(self) -> None: self.assertIsNone(metadata) def test_sidecar_for_adds_sparkle_signature_for_desktop(self) -> None: - with tempfile.TemporaryDirectory() as tmp: - artifact = pathlib.Path(tmp) / "lantern-installer-beta.dmg" - artifact.write_bytes(b"dmg bytes") + cases = { + "macos": "lantern-installer-beta.dmg", + "windows": "lantern-installer-beta.exe", + } + for platform, filename in cases.items(): + with self.subTest(platform=platform), tempfile.TemporaryDirectory() as tmp: + artifact = pathlib.Path(tmp) / filename + artifact.write_bytes(b"installer bytes") + signature = base64.b64encode(b"s" * 64).decode("ascii") + (pathlib.Path(tmp) / f"{artifact.name}.sparkle-signature").write_text( + signature, + encoding="ascii", + ) - completed = subprocess.CompletedProcess( - args=["dart"], - returncode=0, - stdout='sparkle:edSignature="sparkle-signature"\n', - stderr="", - ) - with mock.patch( - "generate_update_metadata.subprocess.run", - return_value=completed, - ) as run: metadata = generate_update_metadata.sidecar_for( artifact, "beta", "9.2.0-beta", "lantern.io", + pathlib.Path(tmp), + "920", ) - self.assertEqual(metadata["platform"], "macos") - self.assertEqual(metadata["sparkle_ed_signature"], "sparkle-signature") - run.assert_called_once_with( - ["dart", "run", "auto_updater:sign_update", str(artifact)], - check=True, - capture_output=True, - text=True, + self.assertEqual(metadata["platform"], platform) + self.assertEqual(metadata["sparkle_version"], "920") + self.assertEqual(metadata["sparkle_ed_signature"], signature) + + def test_sidecar_can_point_at_a_fixture_release(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + artifact = pathlib.Path(tmp) / "lantern-installer-beta.exe" + artifact.write_bytes(b"windows") + signature = base64.b64encode(bytes(range(64))).decode("ascii") + (pathlib.Path(tmp) / f"{artifact.name}.sparkle-signature").write_text( + signature, + encoding="ascii", + ) + + metadata = generate_update_metadata.sidecar_for( + artifact, + "beta", + "9.2.0-beta.e2e.123", + "unused-bucket", + pathlib.Path(tmp), + "123", + "https://github.com/getlantern/lantern-update-fixtures/" + "releases/download/v9.2.0-beta.e2e.123", + ) + + self.assertEqual( + metadata["url"], + "https://github.com/getlantern/lantern-update-fixtures/releases/" + "download/v9.2.0-beta.e2e.123/lantern-installer-beta.exe", ) - def test_sparkle_signature_rejects_unexpected_output(self) -> None: + def test_updater_signature_rejects_invalid_base64(self) -> None: with tempfile.TemporaryDirectory() as tmp: artifact = pathlib.Path(tmp) / "lantern-installer-beta.exe" artifact.write_bytes(b"exe bytes") + (pathlib.Path(tmp) / f"{artifact.name}.sparkle-signature").write_text( + "not a signature", + encoding="ascii", + ) + + with self.assertRaisesRegex(RuntimeError, "invalid EdDSA signature"): + generate_update_metadata.updater_signature( + artifact, + pathlib.Path(tmp), + ) - completed = subprocess.CompletedProcess( - args=["dart"], - returncode=0, - stdout="no signature here", - stderr="", + def test_updater_signature_rejects_wrong_eddsa_length(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + artifact = pathlib.Path(tmp) / "lantern-installer-beta.exe" + artifact.write_bytes(b"exe bytes") + signature = base64.b64encode(b"short signature").decode("ascii") + (pathlib.Path(tmp) / f"{artifact.name}.sparkle-signature").write_text( + signature, + encoding="ascii", ) - with mock.patch( - "generate_update_metadata.subprocess.run", - return_value=completed, - ): - with self.assertRaises(RuntimeError): - generate_update_metadata.sparkle_signature(artifact) + + with self.assertRaisesRegex(RuntimeError, "invalid EdDSA signature length"): + generate_update_metadata.updater_signature( + artifact, + pathlib.Path(tmp), + ) + + def test_updater_signature_requires_native_runner_output(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + artifact = pathlib.Path(tmp) / "lantern-installer-beta.dmg" + artifact.write_bytes(b"dmg bytes") + + with self.assertRaisesRegex(RuntimeError, "missing EdDSA signature"): + generate_update_metadata.updater_signature( + artifact, + pathlib.Path(tmp), + ) if __name__ == "__main__": diff --git a/scripts/ci/resolve_desktop_update_target.py b/scripts/ci/resolve_desktop_update_target.py new file mode 100644 index 0000000000..91609c54b1 --- /dev/null +++ b/scripts/ci/resolve_desktop_update_target.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +"""Resolve and validate a desktop beta target for the update smoke test.""" + +from __future__ import annotations + +import argparse +import base64 +import binascii +import hashlib +import json +import pathlib +import re +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import asdict, dataclass + +from defusedxml import ElementTree as ET +from defusedxml.common import DefusedXmlException + + +SPARKLE_NS = "http://www.andymatuschak.org/xml-namespaces/sparkle" +DEFAULT_APPCAST_URL = ( + "https://update.getlantern.org/update/lantern/appcast.xml?channel=beta" +) +USER_AGENT = "LanternAutoUpdateSmoke/1.0" +DISPLAY_VERSION_RE = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+") + + +class TargetError(Exception): + """Raised when the beta appcast cannot provide a safe update target.""" + + +@dataclass(frozen=True) +class UpdateTarget: + platform: str + appcast_url: str + appcast_sha256: str + target_build: int + fixture_build: int + display_version: str + artifact_url: str + artifact_length: int + ed_signature: str + + @property + def fixture_pubspec_version(self) -> str: + return f"{self.display_version}+{self.fixture_build}" + + +def require(condition: bool, message: str) -> None: + if not condition: + raise TargetError(message) + + +def fetch_appcast(url: str) -> bytes: + request = urllib.request.Request( + url, + headers={ + "Accept": "application/rss+xml, application/xml;q=0.9", + "User-Agent": USER_AGENT, + }, + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + require(response.status == 200, f"appcast returned HTTP {response.status}") + return response.read() + except urllib.error.HTTPError as err: + content_type = err.headers.get_content_type() + body = err.read(256).decode("utf-8", errors="replace").strip() + detail = f": {body}" if content_type == "text/plain" and body else "" + raise TargetError(f"appcast returned HTTP {err.code}{detail}") from err + except urllib.error.URLError as err: + raise TargetError(f"unable to fetch appcast: {err.reason}") from err + + +def parse_target(xml_data: bytes, appcast_url: str, platform: str) -> UpdateTarget: + require(platform in {"macos", "windows"}, f"unsupported platform {platform!r}") + try: + root = ET.fromstring(xml_data) + except (DefusedXmlException, ET.ParseError) as err: + raise TargetError(f"invalid or unsafe appcast XML: {err}") from err + + item = root.find("./channel/item") + require(item is not None, "appcast has no release item") + + version_node = item.find(f"{{{SPARKLE_NS}}}version") + version = ( + version_node.text.strip() + if version_node is not None and version_node.text + else "" + ) + require( + version.isascii() and version.isdigit(), + f"appcast Sparkle version must be numeric; got {version!r}", + ) + target_build = int(version) + require(target_build > 1, "appcast Sparkle build must be greater than 1") + + short_version_node = item.find(f"{{{SPARKLE_NS}}}shortVersionString") + display_version = ( + short_version_node.text.strip() + if short_version_node is not None and short_version_node.text + else "" + ) + require( + DISPLAY_VERSION_RE.fullmatch(display_version) is not None, + "appcast display version must be a valid dotted version", + ) + + platform_name = "macOS" if platform == "macos" else "Windows" + platform_enclosures = [ + enclosure + for enclosure in item.findall("enclosure") + if enclosure.attrib.get(f"{{{SPARKLE_NS}}}os") == platform + ] + require(platform_enclosures, f"appcast has no {platform_name} enclosure") + require( + len(platform_enclosures) == 1, + f"appcast has more than one {platform_name} enclosure", + ) + enclosure = platform_enclosures[0] + + artifact_url = enclosure.attrib.get("url", "").strip() + parsed_url = urllib.parse.urlparse(artifact_url) + require( + parsed_url.scheme == "https" and bool(parsed_url.netloc), + f"{platform_name} update URL must use HTTPS", + ) + expected_suffix = ".dmg" if platform == "macos" else ".exe" + require( + parsed_url.path.lower().endswith(expected_suffix), + f"{platform_name} update URL must point to a {expected_suffix.upper()[1:]}", + ) + + length_text = enclosure.attrib.get("length", "").strip() + require( + length_text.isascii() and length_text.isdigit(), + f"{platform_name} enclosure length must be numeric", + ) + artifact_length = int(length_text) + require(artifact_length > 0, f"{platform_name} enclosure length must be positive") + + signature = enclosure.attrib.get(f"{{{SPARKLE_NS}}}edSignature", "").strip() + require(bool(signature), f"{platform_name} enclosure is missing an EdDSA signature") + try: + decoded_signature = base64.b64decode(signature, validate=True) + except (binascii.Error, ValueError) as err: + raise TargetError(f"{platform_name} EdDSA signature is not valid base64") from err + require( + len(decoded_signature) == 64, + f"{platform_name} EdDSA signature must decode to 64 bytes", + ) + + return UpdateTarget( + platform=platform, + appcast_url=appcast_url, + appcast_sha256=hashlib.sha256(xml_data).hexdigest(), + target_build=target_build, + fixture_build=target_build - 1, + display_version=display_version, + artifact_url=artifact_url, + artifact_length=artifact_length, + ed_signature=signature, + ) + + +def write_fixture_pubspec( + source: pathlib.Path, + destination: pathlib.Path, + target: UpdateTarget, +) -> None: + original = source.read_text(encoding="utf-8") + updated, count = re.subn( + r"(?m)^version:\s*[^\r\n]+$", + f"version: {target.fixture_pubspec_version}", + original, + count=1, + ) + require(count == 1, f"could not replace version in {source}") + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(updated, encoding="utf-8") + + +def write_github_output(path: pathlib.Path, target: UpdateTarget) -> None: + outputs = { + "target_build": str(target.target_build), + "fixture_build": str(target.fixture_build), + "display_version": target.display_version, + "fixture_version": target.fixture_pubspec_version, + } + with path.open("a", encoding="utf-8") as output: + for name, value in outputs.items(): + output.write(f"{name}={value}\n") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--platform", required=True, choices=("macos", "windows")) + parser.add_argument("--appcast-url", default=DEFAULT_APPCAST_URL) + parser.add_argument("--appcast-output", required=True, type=pathlib.Path) + parser.add_argument("--target-output", required=True, type=pathlib.Path) + parser.add_argument("--pubspec-input", required=True, type=pathlib.Path) + parser.add_argument("--pubspec-output", required=True, type=pathlib.Path) + parser.add_argument("--github-output", type=pathlib.Path) + args = parser.parse_args() + + try: + xml_data = fetch_appcast(args.appcast_url) + args.appcast_output.parent.mkdir(parents=True, exist_ok=True) + args.appcast_output.write_bytes(xml_data) + target = parse_target(xml_data, args.appcast_url, args.platform) + args.target_output.parent.mkdir(parents=True, exist_ok=True) + args.target_output.write_text( + json.dumps(asdict(target), indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + write_fixture_pubspec(args.pubspec_input, args.pubspec_output, target) + if args.github_output is not None: + write_github_output(args.github_output, target) + except (OSError, TargetError) as err: + raise SystemExit( + f"unable to resolve {args.platform} beta update target: {err}" + ) from err + + print( + f"[E2E] resolved live beta {target.platform} " + f"{target.display_version} build {target.target_build}; " + f"fixture build is {target.fixture_build}" + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/ci/resolve_desktop_update_target_test.py b/scripts/ci/resolve_desktop_update_target_test.py new file mode 100644 index 0000000000..83d93b564b --- /dev/null +++ b/scripts/ci/resolve_desktop_update_target_test.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import base64 +import pathlib +import sys +import tempfile +import unittest + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) + +import resolve_desktop_update_target as resolver + + +SIGNATURE = base64.b64encode(bytes(range(64))).decode("ascii") +APPCAST_URL = "https://update.example.com/appcast.xml?channel=beta" + + +def appcast( + *, + build: str = "920", + display_version: str = "9.2.0", + os_name: str = "macos", + url: str = "https://example.com/lantern-installer-beta.dmg", + length: str = "12345", + signature: str = SIGNATURE, +) -> bytes: + return f""" + + + + {build} + {display_version} + + + + +""".encode() + + +class ResolveDesktopUpdateTargetTest(unittest.TestCase): + def test_parse_target_accepts_signed_numeric_macos_release(self) -> None: + target = resolver.parse_target(appcast(), APPCAST_URL, "macos") + + self.assertEqual(target.platform, "macos") + self.assertEqual(target.target_build, 920) + self.assertEqual(target.fixture_build, 919) + self.assertEqual(target.display_version, "9.2.0") + self.assertEqual(target.fixture_pubspec_version, "9.2.0+919") + self.assertEqual(target.ed_signature, SIGNATURE) + + def test_parse_target_accepts_signed_numeric_windows_release(self) -> None: + target = resolver.parse_target( + appcast( + os_name="windows", + url="https://example.com/lantern-installer-beta.exe", + ), + APPCAST_URL, + "windows", + ) + + self.assertEqual(target.platform, "windows") + self.assertEqual(target.artifact_url, "https://example.com/lantern-installer-beta.exe") + self.assertEqual(target.artifact_length, 12345) + self.assertEqual(target.ed_signature, SIGNATURE) + + def test_parse_target_rejects_non_numeric_sparkle_version(self) -> None: + with self.assertRaises(resolver.TargetError) as raised: + resolver.parse_target(appcast(build="9.2.0-beta"), APPCAST_URL, "macos") + self.assertEqual( + str(raised.exception), + "appcast Sparkle version must be numeric; got '9.2.0-beta'", + ) + + def test_parse_target_requires_display_version(self) -> None: + with self.assertRaisesRegex(resolver.TargetError, "display version"): + resolver.parse_target(appcast(display_version=""), APPCAST_URL, "macos") + with self.assertRaisesRegex(resolver.TargetError, "display version"): + resolver.parse_target( + appcast(display_version="9.2.0+metadata"), + APPCAST_URL, + "macos", + ) + + def test_parse_target_requires_macos_dmg(self) -> None: + with self.assertRaisesRegex(resolver.TargetError, "no macOS enclosure"): + resolver.parse_target(appcast(os_name="windows"), APPCAST_URL, "macos") + + with self.assertRaisesRegex(resolver.TargetError, "point to a DMG"): + resolver.parse_target( + appcast(url="https://example.com/lantern.exe"), + APPCAST_URL, + "macos", + ) + + def test_parse_target_requires_https(self) -> None: + with self.assertRaisesRegex(resolver.TargetError, "must use HTTPS"): + resolver.parse_target( + appcast(url="http://example.com/lantern.dmg"), + APPCAST_URL, + "macos", + ) + + def test_parse_target_requires_valid_signature(self) -> None: + signatures = ( + "", + "not-base64", + base64.b64encode(b"short").decode("ascii"), + ) + for signature in signatures: + with self.subTest(signature=signature): + with self.assertRaisesRegex(resolver.TargetError, "signature"): + resolver.parse_target(appcast(signature=signature), APPCAST_URL, "macos") + + def test_parse_target_rejects_unsafe_xml(self) -> None: + xml_data = appcast().replace( + b']>920<", b">&build;<") + with self.assertRaisesRegex(resolver.TargetError, "unsafe appcast"): + resolver.parse_target(xml_data, APPCAST_URL, "macos") + + def test_write_fixture_pubspec_only_replaces_version(self) -> None: + target = resolver.parse_target(appcast(), APPCAST_URL, "macos") + with tempfile.TemporaryDirectory() as tmp: + source = pathlib.Path(tmp) / "source.yaml" + destination = pathlib.Path(tmp) / "pubspec.yaml" + source.write_text("name: lantern\nversion: 1.0.0+1\ndescription: Test\n") + + resolver.write_fixture_pubspec(source, destination, target) + + self.assertEqual( + destination.read_text(), + "name: lantern\nversion: 9.2.0+919\ndescription: Test\n", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/ci/verify_fixture_update_artifacts.py b/scripts/ci/verify_fixture_update_artifacts.py new file mode 100644 index 0000000000..bea721c860 --- /dev/null +++ b/scripts/ci/verify_fixture_update_artifacts.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""Verify signed desktop fixture artifacts and their update sidecars.""" + +from __future__ import annotations + +import argparse +import base64 +import binascii +import hashlib +import json +import pathlib +import plistlib +import re +import subprocess +import tempfile +import urllib.parse + + +class VerificationError(Exception): + pass + + +def require(condition: bool, message: str) -> None: + if not condition: + raise VerificationError(message) + + +def public_key(info_plist: pathlib.Path, runner_rc: pathlib.Path) -> bytes: + with info_plist.open("rb") as source: + mac_key = plistlib.load(source).get("SUPublicEDKey", "") + match = re.search( + r'EdDSAPub\s+EDDSA\s+\{"([A-Za-z0-9+/=]+)"\}', + runner_rc.read_text(encoding="utf-8"), + ) + require(match is not None, "Windows EdDSAPub resource is missing") + windows_key = match.group(1) + require(mac_key == windows_key, "macOS and Windows update public keys differ") + try: + decoded = base64.b64decode(mac_key, validate=True) + except (binascii.Error, ValueError) as err: + raise VerificationError("update public key is not valid base64") from err + require(len(decoded) == 32, "Ed25519 public key must decode to 32 bytes") + return decoded + + +def sha256(path: pathlib.Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + for chunk in iter(lambda: source.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def verify_signature(artifact: pathlib.Path, signature: str, key: bytes) -> None: + try: + decoded_signature = base64.b64decode(signature, validate=True) + except (binascii.Error, ValueError) as err: + raise VerificationError(f"invalid signature for {artifact.name}") from err + require(len(decoded_signature) == 64, f"invalid signature length for {artifact.name}") + + # SubjectPublicKeyInfo prefix for a raw Ed25519 public key (RFC 8410). + spki = bytes.fromhex("302a300506032b6570032100") + key + with tempfile.TemporaryDirectory() as temporary_directory: + temporary = pathlib.Path(temporary_directory) + key_path = temporary / "public-key.der" + signature_path = temporary / "signature.bin" + key_path.write_bytes(spki) + signature_path.write_bytes(decoded_signature) + result = subprocess.run( + [ + "openssl", + "pkeyutl", + "-verify", + "-pubin", + "-inkey", + str(key_path), + "-keyform", + "DER", + "-rawin", + "-in", + str(artifact), + "-sigfile", + str(signature_path), + ], + check=False, + capture_output=True, + text=True, + ) + require( + result.returncode == 0, + f"Ed25519 verification failed for {artifact.name}: " + f"{result.stderr.strip() or result.stdout.strip()}", + ) + + +def verify_sidecar( + sidecar: pathlib.Path, + artifact_directory: pathlib.Path, + asset_base_url: str, + key: bytes, +) -> None: + metadata = json.loads(sidecar.read_text(encoding="utf-8")) + filename = metadata.get("filename", "") + require(isinstance(filename, str) and bool(filename), f"invalid filename in {sidecar.name}") + artifact = artifact_directory / filename + require(artifact.is_file(), f"artifact is missing for {sidecar.name}: {filename}") + require(metadata.get("size") == artifact.stat().st_size, f"size mismatch for {filename}") + require(metadata.get("sha256") == sha256(artifact), f"checksum mismatch for {filename}") + expected_url = f"{asset_base_url.rstrip('/')}/{urllib.parse.quote(filename)}" + require(metadata.get("url") == expected_url, f"unexpected fixture URL for {filename}") + sparkle_version = str(metadata.get("sparkle_version", "")) + require( + sparkle_version.isascii() and sparkle_version.isdigit(), + f"invalid Sparkle version for {filename}", + ) + verify_signature(artifact, metadata.get("sparkle_ed_signature", ""), key) + print(f"verified {filename}") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--artifact-dir", required=True, type=pathlib.Path) + parser.add_argument("--metadata-dir", required=True, type=pathlib.Path) + parser.add_argument("--asset-base-url", required=True) + parser.add_argument("--info-plist", default="macos/Runner/Info.plist", type=pathlib.Path) + parser.add_argument("--runner-rc", default="windows/runner/Runner.rc", type=pathlib.Path) + args = parser.parse_args() + + try: + key = public_key(args.info_plist, args.runner_rc) + sidecars = sorted(args.metadata_dir.glob("*.update.json")) + require(bool(sidecars), "no fixture sidecars found") + for sidecar in sidecars: + verify_sidecar(sidecar, args.artifact_dir, args.asset_base_url, key) + except (OSError, VerificationError, json.JSONDecodeError) as err: + raise SystemExit(f"fixture verification failed: {err}") from err + + +if __name__ == "__main__": + main() diff --git a/scripts/ci/verify_update_service.py b/scripts/ci/verify_update_service.py index 8d0fbc4ea6..417ec2f348 100644 --- a/scripts/ci/verify_update_service.py +++ b/scripts/ci/verify_update_service.py @@ -38,6 +38,7 @@ class Config: timeout_seconds: int interval_seconds: int platforms: frozenset[str] + sparkle_version: str = "" class VerificationError(Exception): @@ -169,7 +170,7 @@ def parse_appcast(xml_text: str) -> tuple[str, list[dict[str, str]]]: enclosures.append( { "url": enclosure.attrib.get("url", ""), - "signature": enclosure.attrib.get(f"{{{SPARKLE_NS}}}edSignature", ""), + "ed_signature": enclosure.attrib.get(f"{{{SPARKLE_NS}}}edSignature", ""), "os": enclosure.attrib.get(f"{{{SPARKLE_NS}}}os", ""), } ) @@ -201,7 +202,10 @@ def verify_beta_appcast( for os_name, suffix in required_platforms.items(): enclosure = by_os.get(os_name) require(enclosure is not None, f"beta appcast missing {os_name} enclosure") - require(enclosure["signature"], f"beta appcast {os_name} enclosure missing signature") + require( + enclosure["ed_signature"], + f"beta appcast {os_name} enclosure missing EdDSA signature", + ) require( enclosure["url"].endswith(suffix), f"beta appcast {os_name} URL does not end with {suffix}: {enclosure['url']}", @@ -233,8 +237,20 @@ def run_checks_once(config: Config) -> None: verify_stable_excludes_beta(config.update_url, expected_version, platform) if config.platforms & set(APPCAST_PLATFORMS): - verify_beta_appcast(config.update_url, expected_version, config.platforms) - verify_stable_appcast_excludes_beta(config.update_url, expected_version) + require( + bool(config.sparkle_version.strip()), + "--sparkle-version is required when verifying macOS or Windows appcasts", + ) + expected_sparkle_version = normalize_version(config.sparkle_version) + verify_beta_appcast( + config.update_url, + expected_sparkle_version, + config.platforms, + ) + verify_stable_appcast_excludes_beta( + config.update_url, + expected_sparkle_version, + ) def poll_until_verified(config: Config) -> None: @@ -270,10 +286,17 @@ def main() -> None: help="'all' or comma-separated release platforms", ) parser.add_argument("--version", required=True, help="Release version, with or without leading v") + parser.add_argument("--sparkle-version", default="", help="Desktop bundle build number") parser.add_argument("--timeout-seconds", type=int, default=2700) parser.add_argument("--interval-seconds", type=int, default=60) args = parser.parse_args() + platforms = normalize_platforms(args.platform) + if platforms & set(APPCAST_PLATFORMS) and not args.sparkle_version.strip(): + parser.error( + "--sparkle-version is required when verifying macOS or Windows appcasts" + ) + poll_until_verified( Config( update_url=args.update_url, @@ -281,7 +304,8 @@ def main() -> None: version=args.version, timeout_seconds=args.timeout_seconds, interval_seconds=args.interval_seconds, - platforms=normalize_platforms(args.platform), + platforms=platforms, + sparkle_version=args.sparkle_version, ) ) diff --git a/scripts/ci/verify_update_service_test.py b/scripts/ci/verify_update_service_test.py index df4b12c04f..87285da338 100644 --- a/scripts/ci/verify_update_service_test.py +++ b/scripts/ci/verify_update_service_test.py @@ -17,6 +17,7 @@ class UpdateServiceHandler(BaseHTTPRequestHandler): beta_version = "9.2.0-beta" + beta_appcast_version = "9.2.0-beta" stable_version = "9.1.0" stable_appcast_status = 200 beta_enclosures = [ @@ -58,7 +59,7 @@ def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API. if self.path.endswith("channel=beta"): self.write_xml( self.appcast_xml( - self.beta_version, + self.beta_appcast_version, self.beta_enclosures, ) ) @@ -99,7 +100,9 @@ def write_xml(self, data: str) -> None: @staticmethod def appcast_xml(version: str, enclosures: list[tuple[str, str, str]]) -> str: enclosure_xml = "\n".join( - f'' for os_name, signature, url in enclosures ) @@ -118,6 +121,7 @@ def appcast_xml(version: str, enclosures: list[tuple[str, str, str]]) -> str: class VerifyUpdateServiceTest(unittest.TestCase): def setUp(self) -> None: UpdateServiceHandler.stable_appcast_status = 200 + UpdateServiceHandler.beta_appcast_version = UpdateServiceHandler.beta_version UpdateServiceHandler.beta_enclosures = [ ("macos", "macos-signature", "https://example.com/lantern-installer-beta.dmg"), ("windows", "windows-signature", "https://example.com/lantern-installer-beta.exe"), @@ -135,6 +139,7 @@ def tearDown(self) -> None: self.server.server_close() def test_run_checks_once_accepts_valid_beta_release(self) -> None: + UpdateServiceHandler.beta_appcast_version = "920" verify_update_service.run_checks_once( verify_update_service.Config( update_url=self.update_url, @@ -143,6 +148,7 @@ def test_run_checks_once_accepts_valid_beta_release(self) -> None: timeout_seconds=1, interval_seconds=1, platforms=verify_update_service.normalize_platforms("all"), + sparkle_version="920", ) ) @@ -157,6 +163,7 @@ def test_run_checks_once_accepts_missing_stable_appcast(self) -> None: timeout_seconds=1, interval_seconds=1, platforms=verify_update_service.normalize_platforms("all"), + sparkle_version="9.2.0-beta", ) ) @@ -173,9 +180,26 @@ def test_run_checks_once_accepts_single_platform_appcast_release(self) -> None: timeout_seconds=1, interval_seconds=1, platforms=verify_update_service.normalize_platforms("macos"), + sparkle_version="9.2.0-beta", ) ) + def test_run_checks_once_requires_sparkle_version_for_desktop(self) -> None: + with self.assertRaisesRegex( + verify_update_service.VerificationError, + "--sparkle-version is required", + ): + verify_update_service.run_checks_once( + verify_update_service.Config( + update_url=self.update_url, + channel="beta", + version="v9.2.0-beta", + timeout_seconds=1, + interval_seconds=1, + platforms=verify_update_service.normalize_platforms("windows"), + ) + ) + def test_run_checks_once_skips_appcast_for_android_only_release(self) -> None: UpdateServiceHandler.stable_appcast_status = 404 @@ -239,7 +263,7 @@ def test_parse_appcast_preserves_empty_signature(self) -> None: ) version, enclosures = verify_update_service.parse_appcast(xml_text) self.assertEqual(version, "9.2.0-beta") - self.assertEqual(enclosures[0]["signature"], "") + self.assertEqual(enclosures[0]["ed_signature"], "") def test_parse_appcast_rejects_internal_entities(self) -> None: xml_text = """ diff --git a/scripts/generate_appcast.py b/scripts/generate_appcast.py deleted file mode 100644 index 89ecda5310..0000000000 --- a/scripts/generate_appcast.py +++ /dev/null @@ -1,153 +0,0 @@ -#!/usr/bin/env python3 -""" -generate_appcast.py - -This script is used to fetch GitHub releases and emits a Sparkle-compatible appcast.xml. -It downloads each platform asset via the asset ID endpoint, parses the associated signatur - -Usage: - export GITHUB_TOKEN= - export BUILD_TYPE= (optional, defaults to production) - python3 scripts/generate_appcast.py -""" -import os -import sys -import subprocess -import tempfile -import re -import requests -import xml.etree.ElementTree as ET - -# -------- Configuration -------- -REPO = "getlantern/lantern" -OUT_PATH = "appcast.xml" -PLATFORMS = { - "macos": ".dmg", - "windows": ".exe", - "linux": ".AppImage", -} -# -------------------------------- - - -def indent(elem, level=0): - """Recursively add indentation to XML for pretty-printing.""" - i = "\n" + level * " " - if len(elem): - if not elem.text or not elem.text.strip(): - elem.text = i + " " - for child in elem: - indent(child, level + 1) - if not child.tail or not child.tail.strip(): - child.tail = i - else: - if level and (not elem.tail or not elem.tail.strip()): - elem.tail = i - - -def main(): - token = os.environ.get('GITHUB_TOKEN') - if not token: - sys.exit("Error: GITHUB_TOKEN variable is not set.") - - build_type = os.environ.get('BUILD_TYPE', 'production') - - api_url = f"https://api.github.com/repos/{REPO}/releases" - api_headers = { - "Authorization": f"Bearer {token}", - "Accept": "application/vnd.github.v3+json" - } - - resp = requests.get(api_url, headers=api_headers) - resp.raise_for_status() - releases = resp.json() - - ET.register_namespace('sparkle', 'http://www.andymatuschak.org/xml-namespaces/sparkle') - rss = ET.Element('rss', { - 'version': '2.0', - 'xmlns:sparkle': 'http://www.andymatuschak.org/xml-namespaces/sparkle' - }) - channel = ET.SubElement(rss, 'channel') - ET.SubElement(channel, 'title').text = 'Lantern' - ET.SubElement(channel, 'description').text = 'Latest updates for Lantern' - ET.SubElement(channel, 'language').text = 'en' - - signature_re = re.compile(r'sparkle:edSignature="([^"]+)"') - - for release in releases: - # Always skip drafts - if release.get('draft'): - continue - - # For production appcast, skip prereleases - # For beta appcast, include prereleases (beta releases are marked as prerelease) - if release.get('prerelease') and build_type != 'beta': - continue - - name = release.get('name') or release.get('tag_name') - tag = release.get('tag_name', '').lstrip('v') - short_version = tag.split('-')[0] - pub_date = release.get('published_at') - assets = release.get('assets', []) or [] - - item = ET.SubElement(channel, 'item') - ET.SubElement(item, 'title').text = name - ET.SubElement(item, 'sparkle:version').text = tag - ET.SubElement(item, 'sparkle:shortVersionString').text = short_version - ET.SubElement(item, 'pubDate').text = pub_date - - # one per platform asset - for os_name, ext in PLATFORMS.items(): - asset = next((a for a in assets if a.get('name', '').endswith(ext)), None) - if not asset: - continue - - asset_id = asset['id'] - download_url = f"https://api.github.com/repos/{REPO}/releases/assets/{asset_id}" - headers = { - 'Authorization': f"Bearer {token}", - 'Accept': 'application/octet-stream', - 'X-GitHub-Api-Version': '2022-11-28' - } - - # Download to temp file - tmpf = tempfile.NamedTemporaryFile(delete=False) - try: - with requests.get(download_url, headers=headers, stream=True) as r: - r.raise_for_status() - for chunk in r.iter_content(chunk_size=8192): - tmpf.write(chunk) - tmpf.close() - - proc = subprocess.run( - ['dart', 'run', 'auto_updater:sign_update', tmpf.name], - check=True, capture_output=True - ) - out = proc.stdout.decode().strip() - m = signature_re.search(out) - if not m: - raise RuntimeError(f"Failed to parse signature from: {out}") - sig_value = m.group(1) - - size = asset.get('size') or os.path.getsize(tmpf.name) - - # Add enclosure - ET.SubElement(item, 'enclosure', { - 'url': f"https://github.com/{REPO}/releases/download/v{tag}/{asset['name']}", - 'sparkle:edSignature': sig_value, - 'sparkle:os': os_name, - 'length': str(size), - 'type': 'application/octet-stream' - }) - - finally: - os.unlink(tmpf.name) - - indent(rss) - tree = ET.ElementTree(rss) - os.makedirs(os.path.dirname(OUT_PATH), exist_ok=True) - tree.write(OUT_PATH, encoding='utf-8', xml_declaration=True) - print(f"Generated {OUT_PATH}") - - -if __name__ == '__main__': - main() diff --git a/scripts/requirements.txt b/scripts/requirements.txt index df7eaf0c87..36969f2c4b 100644 --- a/scripts/requirements.txt +++ b/scripts/requirements.txt @@ -1,2 +1 @@ -requests defusedxml diff --git a/test/core/common/app_urls_test.dart b/test/core/common/app_urls_test.dart index 4bf2125b74..38ee133ece 100644 --- a/test/core/common/app_urls_test.dart +++ b/test/core/common/app_urls_test.dart @@ -17,5 +17,12 @@ void main() { 'https://update.getlantern.org/update/lantern/appcast.xml?channel=stable', ); }); + + test('uses the isolated staging feed for E2E fixtures', () { + expect( + AppUrls.appcastFor('beta', autoUpdateE2E: true), + 'https://update.staging.iantem.io/update/lantern/appcast.xml?channel=beta', + ); + }); }); } diff --git a/test/core/updater/updater_test.dart b/test/core/updater/updater_test.dart new file mode 100644 index 0000000000..a7679caf56 --- /dev/null +++ b/test/core/updater/updater_test.dart @@ -0,0 +1,57 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:lantern/core/updater/updater.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('WinSparkle shutdown', () { + test('quits when WinSparkle is ready to install', () async { + final quitStarted = Completer(); + final updater = Updater( + isWindows: true, + quitForUpdate: () async => quitStarted.complete(), + ); + + updater.onUpdaterBeforeQuitForUpdate(null); + + await quitStarted.future; + }); + + test('ignores duplicate shutdown requests', () async { + final quitStarted = Completer(); + final allowQuitToFinish = Completer(); + var quitCalls = 0; + final updater = Updater( + isWindows: true, + quitForUpdate: () async { + quitCalls++; + quitStarted.complete(); + await allowQuitToFinish.future; + }, + ); + + updater.onUpdaterBeforeQuitForUpdate(null); + await quitStarted.future; + updater.onUpdaterBeforeQuitForUpdate(null); + allowQuitToFinish.complete(); + await Future.delayed(Duration.zero); + + expect(quitCalls, 1); + }); + + test('leaves shutdown to Sparkle on other platforms', () async { + var quitCalls = 0; + final updater = Updater( + isWindows: false, + quitForUpdate: () async => quitCalls++, + ); + + updater.onUpdaterBeforeQuitForUpdate(null); + await Future.delayed(Duration.zero); + + expect(quitCalls, 0); + }); + }); +} diff --git a/test_driver/integration_test.dart b/test_driver/integration_test.dart new file mode 100644 index 0000000000..6f82e7ae7e --- /dev/null +++ b/test_driver/integration_test.dart @@ -0,0 +1,5 @@ +import 'package:integration_test/integration_test_driver.dart'; + +// The auto-update smoke uses flutter drive so it can control the installed, +// signed profile fixture and leave it running for the native updater handoff. +Future main() => integrationDriver(); diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index 082987df17..728d673213 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -55,6 +55,15 @@ END IDI_APP_ICON ICON "resources\\app_icon.ico" +///////////////////////////////////////////////////////////////////////////// +// +// WinSparkle +// + +// Keep this in sync with SUPublicEDKey in macos/Runner/Info.plist. +EdDSAPub EDDSA {"J9Pe9z0DTNLMygl0zxG0BWBON2HbTebIO1et3fARypo="} + + ///////////////////////////////////////////////////////////////////////////// // // Version