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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 50 additions & 4 deletions Scripts/install_format_tools.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ FORMAT_TOOLS_DIR="${REPOPROMPT_FORMAT_TOOLS_DIR:-$ROOT_DIR/.build/format-tools}"
MANAGED_SWIFTFORMAT_DIR="$FORMAT_TOOLS_DIR/swiftformat/$SWIFTFORMAT_REQUIRED_VERSION"
MANAGED_SWIFTFORMAT_PATH="$MANAGED_SWIFTFORMAT_DIR/swiftformat"
TEMP_DIR=""
FORMAT_DOWNLOAD_CONNECT_TIMEOUT_SECONDS="${REPOPROMPT_FORMAT_DOWNLOAD_CONNECT_TIMEOUT_SECONDS:-10}"
FORMAT_DOWNLOAD_ATTEMPT_TIMEOUT_SECONDS="${REPOPROMPT_FORMAT_DOWNLOAD_ATTEMPT_TIMEOUT_SECONDS:-120}"
FORMAT_DOWNLOAD_TOTAL_TIMEOUT_SECONDS="${REPOPROMPT_FORMAT_DOWNLOAD_TOTAL_TIMEOUT_SECONDS:-300}"
FORMAT_DOWNLOAD_ATTEMPTS=4

cleanup(){
if [[ -n "$TEMP_DIR" && -d "$TEMP_DIR" ]]; then
Expand Down Expand Up @@ -52,6 +56,20 @@ EOF
done

fail(){ echo "ERROR: $*" >&2; exit 1; }
validate_bounded_positive_integer(){
local name="$1" value="$2" maximum="$3"
[[ "$value" =~ ^[1-9][0-9]*$ ]] || fail "$name must be a positive integer"
(( value <= maximum )) || fail "$name must not exceed $maximum seconds"
}
validate_download_bounds(){
validate_bounded_positive_integer REPOPROMPT_FORMAT_DOWNLOAD_CONNECT_TIMEOUT_SECONDS "$FORMAT_DOWNLOAD_CONNECT_TIMEOUT_SECONDS" 60
validate_bounded_positive_integer REPOPROMPT_FORMAT_DOWNLOAD_ATTEMPT_TIMEOUT_SECONDS "$FORMAT_DOWNLOAD_ATTEMPT_TIMEOUT_SECONDS" 300
validate_bounded_positive_integer REPOPROMPT_FORMAT_DOWNLOAD_TOTAL_TIMEOUT_SECONDS "$FORMAT_DOWNLOAD_TOTAL_TIMEOUT_SECONDS" 600
(( FORMAT_DOWNLOAD_CONNECT_TIMEOUT_SECONDS <= FORMAT_DOWNLOAD_ATTEMPT_TIMEOUT_SECONDS )) ||
fail "REPOPROMPT_FORMAT_DOWNLOAD_CONNECT_TIMEOUT_SECONDS must not exceed REPOPROMPT_FORMAT_DOWNLOAD_ATTEMPT_TIMEOUT_SECONDS"
(( FORMAT_DOWNLOAD_ATTEMPT_TIMEOUT_SECONDS <= FORMAT_DOWNLOAD_TOTAL_TIMEOUT_SECONDS )) ||
fail "REPOPROMPT_FORMAT_DOWNLOAD_ATTEMPT_TIMEOUT_SECONDS must not exceed REPOPROMPT_FORMAT_DOWNLOAD_TOTAL_TIMEOUT_SECONDS"
}
has_tool(){ command -v "$1" >/dev/null 2>&1; }

swiftformat_version_at(){
Expand Down Expand Up @@ -153,20 +171,48 @@ require_install_tool(){
has_tool "$1" || fail "Missing required installer tool: $1."
}

download_swiftformat_archive(){
local archive="$1"
local started now remaining attempt_timeout connect_timeout attempt curl_status=28
started="$(date +%s)"
for ((attempt = 1; attempt <= FORMAT_DOWNLOAD_ATTEMPTS; attempt++)); do
now="$(date +%s)"
remaining=$((FORMAT_DOWNLOAD_TOTAL_TIMEOUT_SECONDS - (now - started)))
(( remaining > 0 )) || break
attempt_timeout="$FORMAT_DOWNLOAD_ATTEMPT_TIMEOUT_SECONDS"
(( attempt_timeout <= remaining )) || attempt_timeout="$remaining"
connect_timeout="$FORMAT_DOWNLOAD_CONNECT_TIMEOUT_SECONDS"
(( connect_timeout <= attempt_timeout )) || connect_timeout="$attempt_timeout"
if curl --fail --location --proto '=https' --tlsv1.2 \
--connect-timeout "$connect_timeout" \
--max-time "$attempt_timeout" \
--silent --show-error \
"$SWIFTFORMAT_ARCHIVE_URL" \
--output "$archive"
then
return 0
else
curl_status=$?
fi
done
return "$curl_status"
}

install_authoritative_swiftformat(){
local archive extracted_path actual_sha installed_version destination_tmp tool

for tool in curl shasum unzip awk mktemp mkdir cp chmod mv rm; do
validate_download_bounds

for tool in curl date shasum unzip awk mktemp mkdir cp chmod mv rm; do
require_install_tool "$tool"
done

TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/repoprompt-swiftformat.XXXXXX")"
archive="$TEMP_DIR/swiftformat.zip"

echo "Installing SwiftFormat $SWIFTFORMAT_REQUIRED_VERSION from the verified official release..."
curl --fail --location --proto '=https' --tlsv1.2 --retry 3 --silent --show-error \
"$SWIFTFORMAT_ARCHIVE_URL" \
--output "$archive"
download_swiftformat_archive "$archive" ||
fail "Unable to download SwiftFormat within the configured total deadline"

actual_sha="$(shasum -a 256 "$archive" | awk '{print $1}')"
if [[ "$actual_sha" != "$SWIFTFORMAT_ARCHIVE_SHA256" ]]; then
Expand Down
196 changes: 149 additions & 47 deletions Scripts/promote_release.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ ARCHIVE_BASENAME="${APP_NAME}-${MARKETING_VERSION}-${BUILD_NUMBER}"
SENTRY_RELEASE_NAME="$BUNDLE_ID@$MARKETING_VERSION+$BUILD_NUMBER"
SENTRY_DEPLOY_ENVIRONMENT="${REPOPROMPT_SENTRY_DEPLOY_ENVIRONMENT:-production}"
SENTRY_API_BASE_URL="${REPOPROMPT_SENTRY_API_BASE_URL:-https://sentry.io/api/0}"
SENTRY_CONNECT_TIMEOUT_SECONDS="${REPOPROMPT_SENTRY_CONNECT_TIMEOUT_SECONDS:-10}"
SENTRY_REQUEST_TIMEOUT_SECONDS="${REPOPROMPT_SENTRY_REQUEST_TIMEOUT_SECONDS:-60}"
RELEASE_DOWNLOAD_CONNECT_TIMEOUT_SECONDS="${REPOPROMPT_RELEASE_DOWNLOAD_CONNECT_TIMEOUT_SECONDS:-10}"
RELEASE_DOWNLOAD_ATTEMPT_TIMEOUT_SECONDS="${REPOPROMPT_RELEASE_DOWNLOAD_ATTEMPT_TIMEOUT_SECONDS:-120}"
RELEASE_DOWNLOAD_TOTAL_TIMEOUT_SECONDS="${REPOPROMPT_RELEASE_DOWNLOAD_TOTAL_TIMEOUT_SECONDS:-600}"
RELEASE_DOWNLOAD_ATTEMPTS=9
SENTRY_OPERATION_ATTEMPTS=3
DISTRIBUTION_APP_BUNDLE_NAME="$DISPLAY_NAME.app"
UPDATE_ZIP_NAME="$ARCHIVE_BASENAME.zip"
DMG_NAME="$ARCHIVE_BASENAME.dmg"
Expand Down Expand Up @@ -60,6 +67,29 @@ require_env() {
[[ -n "${!1:-}" ]] || fail "Missing required environment variable: $1"
}

validate_bounded_positive_integer() {
local name="$1" value="$2" maximum="$3"
[[ "$value" =~ ^[1-9][0-9]*$ ]] || fail "$name must be a positive integer"
(( value <= maximum )) || fail "$name must not exceed $maximum seconds"
}

validate_sentry_network_bounds() {
validate_bounded_positive_integer REPOPROMPT_SENTRY_CONNECT_TIMEOUT_SECONDS "$SENTRY_CONNECT_TIMEOUT_SECONDS" 60
validate_bounded_positive_integer REPOPROMPT_SENTRY_REQUEST_TIMEOUT_SECONDS "$SENTRY_REQUEST_TIMEOUT_SECONDS" 300
(( SENTRY_CONNECT_TIMEOUT_SECONDS <= SENTRY_REQUEST_TIMEOUT_SECONDS )) ||
fail "REPOPROMPT_SENTRY_CONNECT_TIMEOUT_SECONDS must not exceed REPOPROMPT_SENTRY_REQUEST_TIMEOUT_SECONDS"
}

validate_release_download_bounds() {
validate_bounded_positive_integer REPOPROMPT_RELEASE_DOWNLOAD_CONNECT_TIMEOUT_SECONDS "$RELEASE_DOWNLOAD_CONNECT_TIMEOUT_SECONDS" 60
validate_bounded_positive_integer REPOPROMPT_RELEASE_DOWNLOAD_ATTEMPT_TIMEOUT_SECONDS "$RELEASE_DOWNLOAD_ATTEMPT_TIMEOUT_SECONDS" 300
validate_bounded_positive_integer REPOPROMPT_RELEASE_DOWNLOAD_TOTAL_TIMEOUT_SECONDS "$RELEASE_DOWNLOAD_TOTAL_TIMEOUT_SECONDS" 1200
(( RELEASE_DOWNLOAD_CONNECT_TIMEOUT_SECONDS <= RELEASE_DOWNLOAD_ATTEMPT_TIMEOUT_SECONDS )) ||
fail "REPOPROMPT_RELEASE_DOWNLOAD_CONNECT_TIMEOUT_SECONDS must not exceed REPOPROMPT_RELEASE_DOWNLOAD_ATTEMPT_TIMEOUT_SECONDS"
(( RELEASE_DOWNLOAD_ATTEMPT_TIMEOUT_SECONDS <= RELEASE_DOWNLOAD_TOTAL_TIMEOUT_SECONDS )) ||
fail "REPOPROMPT_RELEASE_DOWNLOAD_ATTEMPT_TIMEOUT_SECONDS must not exceed REPOPROMPT_RELEASE_DOWNLOAD_TOTAL_TIMEOUT_SECONDS"
}

require_release_tag_matches_metadata() {
[[ "$RELEASE_TAG" == "v$MARKETING_VERSION" ]] ||
fail "Release tag must match release metadata: expected v$MARKETING_VERSION, got ${RELEASE_TAG:-<missing>}"
Expand All @@ -74,6 +104,7 @@ cleanup() {
trap cleanup EXIT

prepare_sentry_api_access() {
validate_sentry_network_bounds
require_env REPOPROMPT_SENTRY_ORG
require_env REPOPROMPT_SENTRY_PROJECT
local token="${SENTRY_AUTH_TOKEN:-}"
Expand Down Expand Up @@ -110,6 +141,8 @@ sentry_api_request() {
local args=(
--silent
--show-error
--connect-timeout "$SENTRY_CONNECT_TIMEOUT_SECONDS"
--max-time "$SENTRY_REQUEST_TIMEOUT_SECONDS"
--output "$output_file"
--write-out '%{http_code}'
--request "$method"
Expand All @@ -120,19 +153,36 @@ sentry_api_request() {
args+=(--header 'Content-Type: application/json' --data-binary "@$body_file")
fi

local status
status="$(curl "${args[@]}" "$(sentry_deploy_endpoint)")" ||
fail "Unable to call the Sentry deploy API"
if [[ "$status" == "403" ]]; then
fail "Sentry deploy API rejected the organization token (HTTP 403); verify org:ci access to $REPOPROMPT_SENTRY_ORG/$REPOPROMPT_SENTRY_PROJECT"
local status curl_status
if status="$(curl "${args[@]}" "$(sentry_deploy_endpoint)")"; then
curl_status=0
else
curl_status=$?
fi
[[ "$status" =~ ^2[0-9][0-9]$ ]] ||
fail "Sentry deploy API request failed with HTTP $status"
if (( curl_status != 0 )); then
printf 'transport:%s' "$curl_status"
return 0
fi
[[ "$status" =~ ^[0-9]{3}$ ]] || fail "Sentry deploy API returned an invalid HTTP status"
printf '%s' "$status"
}

sentry_request_ambiguous() {
[[ "$1" == transport:* || "$1" =~ ^5[0-9][0-9]$ ]]
}

list_matching_sentry_deploys() {
local output_file="$1"
sentry_api_request GET "$output_file"
local status
status="$(sentry_api_request GET "$output_file")"
if sentry_request_ambiguous "$status"; then
printf 'ambiguous'
return 0
fi
if [[ "$status" == "403" ]]; then
fail "Sentry deploy API rejected the organization token (HTTP 403); verify org:ci access to $REPOPROMPT_SENTRY_ORG/$REPOPROMPT_SENTRY_PROJECT"
fi
[[ "$status" =~ ^2[0-9][0-9]$ ]] || fail "Sentry deploy API request failed with HTTP $status"
jq -e '
type == "array" and
all(.[]; has("environment") and (.environment | type == "string") and
Expand All @@ -150,34 +200,54 @@ preflight_sentry_deploy_access() {
prepare_sentry_api_access
local matches
matches="$(list_matching_sentry_deploys "$TMP_DIR/sentry-deploy-preflight.json")"
[[ "$matches" != "ambiguous" ]] || fail "Unable to call the Sentry deploy API within the configured deadline or server-error boundary"
[[ "$matches" =~ ^[0-9]+$ ]] || fail "Unable to count existing Sentry deploys"
printf 'OK: Sentry deploy access verified for %s.\n' "$SENTRY_RELEASE_NAME"
}

record_verified_sentry_deploy_if_needed() {
local matches
matches="$(list_matching_sentry_deploys "$TMP_DIR/sentry-deploy-record.json")"
[[ "$matches" =~ ^[0-9]+$ ]] || fail "Unable to count existing Sentry deploys"
if (( matches > 0 )); then
printf 'OK: Sentry production deploy already recorded for %s.\n' "$RELEASE_TAG"
return
fi

local body_file="$TMP_DIR/sentry-deploy-create.json"
jq -n \
--arg environment "$SENTRY_DEPLOY_ENVIRONMENT" \
--arg name "$RELEASE_TAG" \
--arg project "$REPOPROMPT_SENTRY_PROJECT" \
'{environment: $environment, name: $name, projects: [$project]}' > "$body_file"
chmod 600 "$body_file"
sentry_api_request POST "$TMP_DIR/sentry-deploy-created.json" "$body_file"
jq -e \
--arg environment "$SENTRY_DEPLOY_ENVIRONMENT" \
--arg name "$RELEASE_TAG" \
'.environment == $environment and .name == $name' \
"$TMP_DIR/sentry-deploy-created.json" >/dev/null ||
fail "Sentry deploy API returned a malformed create response"
printf 'OK: recorded Sentry production deploy for %s.\n' "$RELEASE_TAG"
local matches status attempt
for ((attempt = 1; attempt <= SENTRY_OPERATION_ATTEMPTS; attempt++)); do
matches="$(list_matching_sentry_deploys "$TMP_DIR/sentry-deploy-record.json")"
if [[ "$matches" == "ambiguous" ]]; then
continue
fi
[[ "$matches" =~ ^[0-9]+$ ]] || fail "Unable to count existing Sentry deploys"
if (( matches > 0 )); then
printf 'OK: Sentry production deploy already recorded for %s.\n' "$RELEASE_TAG"
return 0
fi
status="$(sentry_api_request POST "$TMP_DIR/sentry-deploy-created.json" "$body_file")"
if sentry_request_ambiguous "$status"; then
matches="$(list_matching_sentry_deploys "$TMP_DIR/sentry-deploy-reconcile.json")"
if [[ "$matches" == "ambiguous" ]]; then
continue
fi
[[ "$matches" =~ ^[0-9]+$ ]] || fail "Unable to count Sentry deploys during reconciliation"
if (( matches > 0 )); then
printf 'OK: Sentry production deploy confirmed after an ambiguous response for %s.\n' "$RELEASE_TAG"
return 0
fi
continue
fi
[[ "$status" =~ ^2[0-9][0-9]$ ]] || fail "Sentry deploy API request failed with HTTP $status"
jq -e \
--arg environment "$SENTRY_DEPLOY_ENVIRONMENT" \
--arg name "$RELEASE_TAG" \
'.environment == $environment and .name == $name' \
"$TMP_DIR/sentry-deploy-created.json" >/dev/null ||
fail "Sentry deploy API returned a malformed create response"
printf 'OK: recorded Sentry production deploy for %s.\n' "$RELEASE_TAG"
return 0
done
fail "Unable to reconcile Sentry deploy state after bounded transport or HTTP 5xx responses; no further mutation was attempted"
}

source_gh() {
Expand All @@ -189,13 +259,40 @@ update_gh() {
}

curl_anonymous() {
env -u GH_TOKEN -u GITHUB_TOKEN curl \
--fail \
--location \
--retry 8 \
--retry-delay 3 \
--retry-all-errors \
"$@"
validate_release_download_bounds
local started now remaining attempt_timeout connect_timeout attempt curl_status=28 stdout_file
stdout_file="$(mktemp "${TMPDIR:-/tmp}/repoprompt-release-curl.XXXXXX")"
started="$(date +%s)"
for ((attempt = 1; attempt <= RELEASE_DOWNLOAD_ATTEMPTS; attempt++)); do
now="$(date +%s)"
remaining=$((RELEASE_DOWNLOAD_TOTAL_TIMEOUT_SECONDS - (now - started)))
(( remaining > 0 )) || break
attempt_timeout="$RELEASE_DOWNLOAD_ATTEMPT_TIMEOUT_SECONDS"
(( attempt_timeout <= remaining )) || attempt_timeout="$remaining"
connect_timeout="$RELEASE_DOWNLOAD_CONNECT_TIMEOUT_SECONDS"
(( connect_timeout <= attempt_timeout )) || connect_timeout="$attempt_timeout"
: > "$stdout_file"
if env -u GH_TOKEN -u GITHUB_TOKEN curl \
--fail \
--location \
--connect-timeout "$connect_timeout" \
--max-time "$attempt_timeout" \
"$@" > "$stdout_file"
then
cat "$stdout_file"
rm -f "$stdout_file"
return 0
else
curl_status=$?
fi
(( attempt < RELEASE_DOWNLOAD_ATTEMPTS )) || break
now="$(date +%s)"
remaining=$((RELEASE_DOWNLOAD_TOTAL_TIMEOUT_SECONDS - (now - started)))
(( remaining > 3 )) || break
sleep 3
done
rm -f "$stdout_file"
return "$curl_status"
}

asset_snapshot() {
Expand Down Expand Up @@ -366,7 +463,7 @@ verify_source_release() {
require_env SOURCE_GH_TOKEN
require_env SPARKLE_PRIVATE_KEY
require_release_tag_matches_metadata
for command in codesign curl diff ditto gh hdiutil jq plutil python3 shasum stat xcrun; do
for command in cat codesign curl date diff ditto gh hdiutil jq mktemp plutil python3 shasum sleep stat xcrun; do
require_command "$command"
done
require_file "$SIGN_UPDATE"
Expand Down Expand Up @@ -467,10 +564,13 @@ recheck_source_assets() {
verify_strictly_newer_build() {
local latest_json_file="$TMP_DIR/latest-release.json"
local latest_status latest_json latest_tag latest_appcast latest_build
validate_release_download_bounds
if ! latest_status="$(env -u GH_TOKEN -u GITHUB_TOKEN curl \
--location \
--silent \
--show-error \
--connect-timeout "$RELEASE_DOWNLOAD_CONNECT_TIMEOUT_SECONDS" \
--max-time "$RELEASE_DOWNLOAD_ATTEMPT_TIMEOUT_SECONDS" \
--header "Authorization: Bearer $PUBLIC_UPDATE_GH_TOKEN" \
--header "Accept: application/vnd.github+json" \
--output "$latest_json_file" \
Expand Down Expand Up @@ -651,18 +751,20 @@ verify_anonymous_publish() {
printf 'OK: anonymous release smoke passed for %s.\n' "$RELEASE_TAG"
}

case "$MODE" in
verify)
verify_source_release
;;
promote)
verify_source_release
preflight_sentry_deploy_access
publish_reviewed_release
verify_anonymous_publish
record_verified_sentry_deploy_if_needed
;;
*)
fail "Usage: $0 verify|promote"
;;
esac
if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
case "$MODE" in
verify)
verify_source_release
;;
promote)
verify_source_release
preflight_sentry_deploy_access
publish_reviewed_release
verify_anonymous_publish
record_verified_sentry_deploy_if_needed
;;
*)
fail "Usage: $0 verify|promote"
;;
esac
fi
Loading
Loading