From 869b59ec352381d417e31afb40805d07766b81ed Mon Sep 17 00:00:00 2001 From: SB Date: Sat, 18 Jul 2026 05:08:24 +0900 Subject: [PATCH] fix(release): bound network operations --- Scripts/install_format_tools.sh | 25 ++- Scripts/promote_release.sh | 143 ++++++++++---- Scripts/release.sh | 179 ++++++++++++----- Scripts/test_release_tooling.py | 336 +++++++++++++++++++++++++++++++- 4 files changed, 591 insertions(+), 92 deletions(-) diff --git a/Scripts/install_format_tools.sh b/Scripts/install_format_tools.sh index fed7e9ca1..9a9d50934 100755 --- a/Scripts/install_format_tools.sh +++ b/Scripts/install_format_tools.sh @@ -11,6 +11,9 @@ 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_RETRY_TIMEOUT_SECONDS="${REPOPROMPT_FORMAT_DOWNLOAD_RETRY_TIMEOUT_SECONDS:-300}" cleanup(){ if [[ -n "$TEMP_DIR" && -d "$TEMP_DIR" ]]; then @@ -52,6 +55,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_RETRY_TIMEOUT_SECONDS "$FORMAT_DOWNLOAD_RETRY_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_RETRY_TIMEOUT_SECONDS )) || + fail "REPOPROMPT_FORMAT_DOWNLOAD_ATTEMPT_TIMEOUT_SECONDS must not exceed REPOPROMPT_FORMAT_DOWNLOAD_RETRY_TIMEOUT_SECONDS" +} has_tool(){ command -v "$1" >/dev/null 2>&1; } swiftformat_version_at(){ @@ -156,6 +173,8 @@ require_install_tool(){ install_authoritative_swiftformat(){ local archive extracted_path actual_sha installed_version destination_tmp tool + validate_download_bounds + for tool in curl shasum unzip awk mktemp mkdir cp chmod mv rm; do require_install_tool "$tool" done @@ -164,7 +183,11 @@ install_authoritative_swiftformat(){ 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 \ + curl --fail --location --proto '=https' --tlsv1.2 --retry 3 \ + --connect-timeout "$FORMAT_DOWNLOAD_CONNECT_TIMEOUT_SECONDS" \ + --max-time "$FORMAT_DOWNLOAD_ATTEMPT_TIMEOUT_SECONDS" \ + --retry-max-time "$FORMAT_DOWNLOAD_RETRY_TIMEOUT_SECONDS" \ + --silent --show-error \ "$SWIFTFORMAT_ARCHIVE_URL" \ --output "$archive" diff --git a/Scripts/promote_release.sh b/Scripts/promote_release.sh index e20064039..ad316693a 100755 --- a/Scripts/promote_release.sh +++ b/Scripts/promote_release.sh @@ -21,6 +21,12 @@ 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_RETRY_TIMEOUT_SECONDS="${REPOPROMPT_RELEASE_DOWNLOAD_RETRY_TIMEOUT_SECONDS:-600}" +SENTRY_OPERATION_ATTEMPTS=3 DISTRIBUTION_APP_BUNDLE_NAME="$DISPLAY_NAME.app" UPDATE_ZIP_NAME="$ARCHIVE_BASENAME.zip" DMG_NAME="$ARCHIVE_BASENAME.dmg" @@ -60,6 +66,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_RETRY_TIMEOUT_SECONDS "$RELEASE_DOWNLOAD_RETRY_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_RETRY_TIMEOUT_SECONDS )) || + fail "REPOPROMPT_RELEASE_DOWNLOAD_ATTEMPT_TIMEOUT_SECONDS must not exceed REPOPROMPT_RELEASE_DOWNLOAD_RETRY_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:-}" @@ -74,6 +103,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:-}" @@ -110,6 +140,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" @@ -120,19 +152,32 @@ 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 + if (( curl_status != 0 )); then + printf 'transport:%s' "$curl_status" + return 0 fi - [[ "$status" =~ ^2[0-9][0-9]$ ]] || - fail "Sentry deploy API request failed with HTTP $status" + [[ "$status" =~ ^[0-9]{3}$ ]] || fail "Sentry deploy API returned an invalid HTTP status" + printf '%s' "$status" } list_matching_sentry_deploys() { local output_file="$1" - sentry_api_request GET "$output_file" + local status + status="$(sentry_api_request GET "$output_file")" + if [[ "$status" == transport:* ]]; then + printf 'transport' + 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 @@ -150,19 +195,12 @@ preflight_sentry_deploy_access() { prepare_sentry_api_access local matches matches="$(list_matching_sentry_deploys "$TMP_DIR/sentry-deploy-preflight.json")" + [[ "$matches" != "transport" ]] || fail "Unable to call the Sentry deploy API within the configured deadline" [[ "$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" \ @@ -170,14 +208,32 @@ record_verified_sentry_deploy_if_needed() { --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" == "transport" ]]; 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 [[ "$status" == transport:* ]]; then + 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 failures; no further mutation was attempted" } source_gh() { @@ -189,12 +245,16 @@ update_gh() { } curl_anonymous() { + validate_release_download_bounds env -u GH_TOKEN -u GITHUB_TOKEN curl \ --fail \ --location \ --retry 8 \ --retry-delay 3 \ --retry-all-errors \ + --connect-timeout "$RELEASE_DOWNLOAD_CONNECT_TIMEOUT_SECONDS" \ + --max-time "$RELEASE_DOWNLOAD_ATTEMPT_TIMEOUT_SECONDS" \ + --retry-max-time "$RELEASE_DOWNLOAD_RETRY_TIMEOUT_SECONDS" \ "$@" } @@ -467,10 +527,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" \ @@ -651,18 +714,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 diff --git a/Scripts/release.sh b/Scripts/release.sh index 3c0ad53c6..4b61a4b01 100755 --- a/Scripts/release.sh +++ b/Scripts/release.sh @@ -23,6 +23,9 @@ BUILD_ARTIFACT_MANIFEST="$ROOT_DIR/.build/release/$APP_NAME-artifact-manifest.js SENTRY_SYMBOLS_DIR="$ROOT_DIR/.build/sentry-symbols/release" SENTRY_RELEASE_NAME="$BUNDLE_ID@$MARKETING_VERSION+$BUILD_NUMBER" 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}" +SENTRY_OPERATION_ATTEMPTS=3 SENTRY_CURL_CONFIG="" FINAL_ARTIFACT_MANIFEST="$DIST_DIR/$ARCHIVE_BASENAME-artifact-manifest.json" STAGE_ARCHIVE="$DIST_DIR/$ARCHIVE_BASENAME-stage.zip" @@ -53,6 +56,21 @@ require_env() { [[ -n "${!1:-}" ]] || fail "Missing required environment variable: $1" } +validate_bounded_positive_integer() { + local name="$1" + local value="$2" + local 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" +} + sentry_linking_enabled() { [[ "${REPOPROMPT_ENABLE_SENTRY:-}" == "1" ]] } @@ -184,18 +202,23 @@ require_staged_sentry_symbols_when_enabled() { fi } -require_sentry_publish_configuration() { +require_sentry_api_configuration() { sentry_linking_enabled || return 0 [[ -n "${SENTRY_AUTH_TOKEN:-}" || -n "${REPOPROMPT_SENTRY_AUTH_TOKEN_FILE:-${SENTRY_AUTH_TOKEN_FILE:-}}" ]] || fail "Official Sentry-enabled release publishing requires SENTRY_AUTH_TOKEN or REPOPROMPT_SENTRY_AUTH_TOKEN_FILE for Sentry release metadata and debug symbol upload." require_env REPOPROMPT_SENTRY_ORG require_env REPOPROMPT_SENTRY_PROJECT require_command curl require_command jq +} + +require_sentry_publish_configuration() { + require_sentry_api_configuration require_command sentry-cli } prepare_sentry_api_access() { sentry_linking_enabled || return 0 + validate_sentry_network_bounds [[ -n "$SENTRY_CURL_CONFIG" && -f "$SENTRY_CURL_CONFIG" ]] && return 0 [[ -n "$TMP_DIR" ]] || fail "Sentry API access requires an initialized release workspace" local token="${SENTRY_AUTH_TOKEN:-}" @@ -246,6 +269,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" @@ -256,13 +281,28 @@ sentry_api_request() { args+=(--header 'Content-Type: application/json' --data-binary "@$body_file") fi - local status - status="$(curl "${args[@]}" "$endpoint")" || - fail "Unable to call the Sentry release API" + local status curl_status + if status="$(curl "${args[@]}" "$endpoint")"; then + curl_status=0 + else + curl_status=$? + fi + if (( curl_status != 0 )); then + printf 'transport:%s' "$curl_status" + return 0 + fi [[ "$status" =~ ^[0-9]{3}$ ]] || fail "Sentry release API returned an invalid HTTP status" printf '%s' "$status" } +sentry_transport_failed() { + [[ "$1" == transport:* ]] +} + +fail_sentry_unknown_transport_state() { + fail "Unable to reconcile Sentry release state after bounded transport failures; no further mutation was attempted" +} + fail_sentry_release_api_status() { local action="$1" local status="$2" @@ -298,6 +338,9 @@ preflight_sentry_release_access() { local response_file="$TMP_DIR/sentry-release-preflight.json" local status status="$(sentry_api_request GET "$(sentry_release_preflight_endpoint)" "$response_file")" + if sentry_transport_failed "$status"; then + fail "Unable to verify Sentry release access within the configured network deadline" + fi [[ "$status" =~ ^2[0-9][0-9]$ ]] || fail_sentry_release_api_status "verify release access" "$status" jq -e 'type == "array" and all(.[]; .version | type == "string")' "$response_file" >/dev/null || @@ -313,25 +356,37 @@ prepare_sentry_release() { [[ -n "$source_repository" ]] || fail "Missing SOURCE_GITHUB_REPOSITORY for Sentry commit association" printf 'Preparing Sentry release %s for %s/%s.\n' "$SENTRY_RELEASE_NAME" "$REPOPROMPT_SENTRY_ORG" "$REPOPROMPT_SENTRY_PROJECT" local release_response="$TMP_DIR/sentry-release.json" - local status - status="$(sentry_api_request GET "$(sentry_release_endpoint)" "$release_response")" - if [[ "$status" == "404" ]]; then - local create_body="$TMP_DIR/sentry-release-create.json" - jq -n \ - --arg version "$SENTRY_RELEASE_NAME" \ - --arg project "$REPOPROMPT_SENTRY_PROJECT" \ - --arg repository "$source_repository" \ - --arg commit "$RELEASE_COMMIT" \ - '{version: $version, projects: [$project], refs: [{repository: $repository, commit: $commit}]}' \ - > "$create_body" - chmod 600 "$create_body" - status="$(sentry_api_request POST "$(sentry_releases_endpoint)" "$release_response" "$create_body")" - [[ "$status" =~ ^2[0-9][0-9]$ ]] || - fail_sentry_release_api_status "create release $SENTRY_RELEASE_NAME" "$status" - elif [[ ! "$status" =~ ^2[0-9][0-9]$ ]]; then - fail_sentry_release_api_status "look up release $SENTRY_RELEASE_NAME" "$status" - fi - validate_sentry_release_response "$release_response" "release preparation" + local create_body="$TMP_DIR/sentry-release-create.json" + local status attempt prepared=0 + jq -n \ + --arg version "$SENTRY_RELEASE_NAME" \ + --arg project "$REPOPROMPT_SENTRY_PROJECT" \ + --arg repository "$source_repository" \ + --arg commit "$RELEASE_COMMIT" \ + '{version: $version, projects: [$project], refs: [{repository: $repository, commit: $commit}]}' \ + > "$create_body" + chmod 600 "$create_body" + + for ((attempt = 1; attempt <= SENTRY_OPERATION_ATTEMPTS; attempt++)); do + status="$(sentry_api_request GET "$(sentry_release_endpoint)" "$release_response")" + if sentry_transport_failed "$status"; then + continue + fi + if [[ "$status" == "404" ]]; then + status="$(sentry_api_request POST "$(sentry_releases_endpoint)" "$release_response" "$create_body")" + if sentry_transport_failed "$status"; then + continue + fi + [[ "$status" =~ ^2[0-9][0-9]$ ]] || + fail_sentry_release_api_status "create release $SENTRY_RELEASE_NAME" "$status" + elif [[ ! "$status" =~ ^2[0-9][0-9]$ ]]; then + fail_sentry_release_api_status "look up release $SENTRY_RELEASE_NAME" "$status" + fi + validate_sentry_release_response "$release_response" "release preparation" + prepared=1 + break + done + (( prepared == 1 )) || fail_sentry_unknown_transport_state local refs_body="$TMP_DIR/sentry-release-refs.json" jq -n \ @@ -339,38 +394,67 @@ prepare_sentry_release() { --arg commit "$RELEASE_COMMIT" \ '{refs: [{repository: $repository, commit: $commit}]}' > "$refs_body" chmod 600 "$refs_body" - status="$(sentry_api_request PUT "$(sentry_release_endpoint)" "$release_response" "$refs_body")" - [[ "$status" =~ ^2[0-9][0-9]$ ]] || - fail_sentry_release_api_status "associate release commits" "$status" - validate_sentry_release_response "$release_response" "commit association" + for ((attempt = 1; attempt <= SENTRY_OPERATION_ATTEMPTS; attempt++)); do + status="$(sentry_api_request PUT "$(sentry_release_endpoint)" "$release_response" "$refs_body")" + if sentry_transport_failed "$status"; then + continue + fi + [[ "$status" =~ ^2[0-9][0-9]$ ]] || + fail_sentry_release_api_status "associate release commits" "$status" + validate_sentry_release_response "$release_response" "commit association" + return 0 + done + fail "Unable to associate Sentry release commits after bounded retries of the idempotent request" } finalize_sentry_release() { sentry_linking_enabled || return 0 prepare_sentry_api_access local release_response="$TMP_DIR/sentry-release-finalize.json" - local status - status="$(sentry_api_request GET "$(sentry_release_endpoint)" "$release_response")" - [[ "$status" =~ ^2[0-9][0-9]$ ]] || - fail_sentry_release_api_status "look up release $SENTRY_RELEASE_NAME before finalization" "$status" - validate_sentry_release_response "$release_response" "release finalization" - if jq -e '.dateReleased | type == "string"' "$release_response" >/dev/null; then - printf 'OK: Sentry release %s is already finalized.\n' "$SENTRY_RELEASE_NAME" - return - fi - jq -e '.dateReleased == null' "$release_response" >/dev/null || - fail "Sentry release API returned malformed finalization state" - local finalize_body="$TMP_DIR/sentry-release-finalize-body.json" + local status attempt jq -n --arg date_released "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" \ '{dateReleased: $date_released}' > "$finalize_body" chmod 600 "$finalize_body" - status="$(sentry_api_request PUT "$(sentry_release_endpoint)" "$release_response" "$finalize_body")" - [[ "$status" =~ ^2[0-9][0-9]$ ]] || - fail_sentry_release_api_status "finalize release $SENTRY_RELEASE_NAME" "$status" - validate_sentry_release_response "$release_response" "release finalization" - jq -e '.dateReleased | type == "string"' "$release_response" >/dev/null || - fail "Sentry release API did not confirm finalization" + + for ((attempt = 1; attempt <= SENTRY_OPERATION_ATTEMPTS; attempt++)); do + status="$(sentry_api_request GET "$(sentry_release_endpoint)" "$release_response")" + if sentry_transport_failed "$status"; then + continue + fi + [[ "$status" =~ ^2[0-9][0-9]$ ]] || + fail_sentry_release_api_status "look up release $SENTRY_RELEASE_NAME before finalization" "$status" + validate_sentry_release_response "$release_response" "release finalization" + if jq -e '.dateReleased | type == "string"' "$release_response" >/dev/null; then + printf 'OK: Sentry release %s is already finalized.\n' "$SENTRY_RELEASE_NAME" + return 0 + fi + jq -e '.dateReleased == null' "$release_response" >/dev/null || + fail "Sentry release API returned malformed finalization state" + + status="$(sentry_api_request PUT "$(sentry_release_endpoint)" "$release_response" "$finalize_body")" + if sentry_transport_failed "$status"; then + continue + fi + [[ "$status" =~ ^2[0-9][0-9]$ ]] || + fail_sentry_release_api_status "finalize release $SENTRY_RELEASE_NAME" "$status" + validate_sentry_release_response "$release_response" "release finalization" + jq -e '.dateReleased | type == "string"' "$release_response" >/dev/null || + fail "Sentry release API did not confirm finalization" + return 0 + done + fail_sentry_unknown_transport_state +} + +recover_sentry_finalization() { + [[ "${REPOPROMPT_ENABLE_SENTRY:-}" == "1" ]] || + fail "finalize-sentry requires REPOPROMPT_ENABLE_SENTRY=1" + require_env RELEASE_TAG + require_release_tag_matches_metadata + require_sentry_api_configuration + TMP_DIR="$(mktemp -d)" + preflight_sentry_release_access + finalize_sentry_release } upload_required_sentry_symbols() { @@ -555,7 +639,7 @@ publish_staged_release() { ) local existing_release_state="" if existing_release_state="$(gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft 2>/dev/null)"; then - fail "GitHub release $RELEASE_TAG already exists (isDraft=$existing_release_state). Refusing to repeat Sentry finalization; inspect the existing draft and Sentry release before manual recovery." + fail "GitHub release $RELEASE_TAG already exists (isDraft=$existing_release_state). Refusing to repeat publish-staged; inspect the existing release, then run release.sh finalize-sentry to idempotently recover Sentry finalization." fi gh release create "${release_args[@]}" printf 'Created draft GitHub release assets for %s.\n' "$RELEASE_TAG" @@ -572,6 +656,7 @@ if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then artifact) package_release_candidate ;; stage-publish) stage_publish_release ;; publish-staged) publish_staged_release ;; - *) fail "Usage: $0 sync-cli-version|preflight|artifact|stage-publish|publish-staged" ;; + finalize-sentry) recover_sentry_finalization ;; + *) fail "Usage: $0 sync-cli-version|preflight|artifact|stage-publish|publish-staged|finalize-sentry" ;; esac fi diff --git a/Scripts/test_release_tooling.py b/Scripts/test_release_tooling.py index 5ebcd0e88..cbcf04d41 100644 --- a/Scripts/test_release_tooling.py +++ b/Scripts/test_release_tooling.py @@ -1270,10 +1270,13 @@ def run_sentry_prepare_fixture( self, lookup_mode: str, attempts: int = 1, + action: str = "full", + env_overrides: dict[str, str] | None = None, ) -> tuple[subprocess.CompletedProcess[str], list[dict[str, object]]]: temp_dir = Path(tempfile.mkdtemp()) self.addCleanup(shutil.rmtree, temp_dir, True) call_log = temp_dir / "sentry-api-calls.jsonl" + counter_file = temp_dir / "sentry-api-counters.json" release_state = temp_dir / "sentry-release.json" api_tmp = temp_dir / "api-tmp" api_tmp.mkdir() @@ -1292,6 +1295,13 @@ def run_sentry_prepare_fixture( def option(name): return args[args.index(name) + 1] +if option("--connect-timeout") != os.environ.get("EXPECTED_CONNECT_TIMEOUT", "10"): + raise SystemExit(88) +if option("--max-time") != os.environ.get("EXPECTED_REQUEST_TIMEOUT", "60"): + raise SystemExit(89) +if "--retry" in args or "--retry-all-errors" in args: + raise SystemExit(87) + config = Path(option("--config")) if stat.S_IMODE(config.stat().st_mode) != 0o600: raise SystemExit(90) @@ -1320,14 +1330,27 @@ def option(name): body = json.loads(Path(body_arg[1:]).read_text(encoding="utf-8")) with Path(os.environ["SENTRY_CALL_LOG"]).open("a", encoding="utf-8") as handle: - handle.write(json.dumps({"method": method, "url": url, "body": body}) + "\\n") + handle.write(json.dumps({ + "method": method, + "url": url, + "body": body, + "connect_timeout": option("--connect-timeout"), + "max_time": option("--max-time"), + }) + "\\n") state_path = Path(os.environ["SENTRY_RELEASE_STATE"]) +counter_path = Path(os.environ["SENTRY_COUNTER_FILE"]) parsed = urlparse(url) is_preflight = parsed.query != "" is_collection = parsed.path.endswith("/releases/") version = unquote(parsed.path.rstrip("/").split("/")[-1]) +def bump(key): + counters = json.loads(counter_path.read_text(encoding="utf-8")) if counter_path.exists() else {} + counters[key] = counters.get(key, 0) + 1 + counter_path.write_text(json.dumps(counters), encoding="utf-8") + return counters[key] + def release_payload(): state = json.loads(state_path.read_text(encoding="utf-8")) return { @@ -1346,21 +1369,42 @@ def release_payload(): else: status, response = 200, [] elif method == "GET" and not is_collection: + if scenario in {"existing-finalized", "existing-unfinalized"} and not state_path.exists(): + date_released = "2026-01-01T00:00:00Z" if scenario == "existing-finalized" else None + state_path.write_text(json.dumps({"version": version, "dateReleased": date_released}), encoding="utf-8") + if scenario == "unknown-create" and counter_path.exists() and json.loads(counter_path.read_text(encoding="utf-8")).get("create", 0) > 0: + raise SystemExit(28) if state_path.exists(): status, response = 200, release_payload() else: status, response = 404, {"detail": "SECRET_BODY_MARKER"} elif method == "POST" and is_collection: + create_attempt = bump("create") + if scenario == "ambiguous-create-lost" and create_attempt == 1: + raise SystemExit(28) + if scenario == "unknown-create": + raise SystemExit(28) state_path.write_text( json.dumps({"version": body["version"], "dateReleased": None}), encoding="utf-8", ) + if scenario == "ambiguous-create-landed" and create_attempt == 1: + raise SystemExit(28) status, response = 201, release_payload() elif method == "PUT" and not is_collection and state_path.exists(): state = json.loads(state_path.read_text(encoding="utf-8")) if "dateReleased" in body: + finalize_attempt = bump("finalize") + if scenario == "ambiguous-finalize-lost" and finalize_attempt == 1: + raise SystemExit(28) state["dateReleased"] = body["dateReleased"] state_path.write_text(json.dumps(state), encoding="utf-8") + if scenario == "ambiguous-finalize-landed" and finalize_attempt == 1: + raise SystemExit(28) + elif "refs" in body: + refs_attempt = bump("refs") + if scenario == "ambiguous-refs" and refs_attempt == 1: + raise SystemExit(28) status, response = 200, release_payload() else: status, response = 500, {"detail": "unexpected fixture request", "version": version} @@ -1384,19 +1428,37 @@ def release_payload(): "RELEASE_COMMIT": "0123456789abcdef", "SENTRY_LOOKUP_MODE": lookup_mode, "SENTRY_CALL_LOG": str(call_log), + "SENTRY_COUNTER_FILE": str(counter_file), "SENTRY_RELEASE_STATE": str(release_state), "FIXTURE_TMP_DIR": str(api_tmp), "ATTEMPTS": str(attempts), + "EXPECTED_CONNECT_TIMEOUT": "10", + "EXPECTED_REQUEST_TIMEOUT": "60", } ) + if env_overrides: + env.update(env_overrides) + if action in {"recover", "recover-disabled"}: + metadata = dict( + line.split("=", 1) + for line in (SCRIPT_DIR.parent / "version.env").read_text(encoding="utf-8").splitlines() + if line and not line.startswith("#") + ) + env["RELEASE_TAG"] = f'v{metadata["MARKETING_VERSION"].strip(chr(34))}' + if action == "recover-disabled": + env.pop("REPOPROMPT_ENABLE_SENTRY", None) + shell_action = "recover_sentry_finalization" + else: + shell_action = ( + "preflight_sentry_release_access; " + "for ((attempt = 0; attempt < ATTEMPTS; attempt++)); do prepare_sentry_release; done; " + "finalize_sentry_release; finalize_sentry_release" + ) result = subprocess.run( [ "bash", "-c", - 'source "$1"; TMP_DIR="$FIXTURE_TMP_DIR"; ' - "preflight_sentry_release_access; " - "for ((attempt = 0; attempt < ATTEMPTS; attempt++)); do prepare_sentry_release; done; " - "finalize_sentry_release; finalize_sentry_release", + 'source "$1"; TMP_DIR="$FIXTURE_TMP_DIR"; ' + shell_action, "sentry-release-test", str(SCRIPT_DIR / "release.sh"), ], @@ -1449,6 +1511,227 @@ def test_sentry_release_prepare_does_not_create_after_lookup_failure(self) -> No self.assertNotIn("fixture-token", result.stdout + result.stderr) self.assertNotIn("SECRET_BODY_MARKER", result.stdout + result.stderr) + def test_sentry_release_requests_are_bounded_without_curl_mutation_retries(self) -> None: + result, calls = self.run_sentry_prepare_fixture("not-found-once") + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertGreater(len(calls), 0) + self.assertTrue(all(call["connect_timeout"] == "10" for call in calls)) + self.assertTrue(all(call["max_time"] == "60" for call in calls)) + + def test_sentry_release_recovers_ambiguous_create_outcomes_by_observation(self) -> None: + for scenario, expected_posts in ( + ("ambiguous-create-landed", 1), + ("ambiguous-create-lost", 2), + ): + with self.subTest(scenario=scenario): + result, calls = self.run_sentry_prepare_fixture(scenario) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(len([call for call in calls if call["method"] == "POST"]), expected_posts) + + def test_sentry_release_recovers_ambiguous_finalize_outcomes_by_observation(self) -> None: + for scenario, expected_finalizations in ( + ("ambiguous-finalize-landed", 1), + ("ambiguous-finalize-lost", 2), + ): + with self.subTest(scenario=scenario): + result, calls = self.run_sentry_prepare_fixture(scenario) + self.assertEqual(result.returncode, 0, result.stderr) + finalizations = [ + call + for call in calls + if call["method"] == "PUT" and "dateReleased" in (call["body"] or {}) + ] + self.assertEqual(len(finalizations), expected_finalizations) + + def test_sentry_release_retries_identical_idempotent_refs_after_transport_failure(self) -> None: + result, calls = self.run_sentry_prepare_fixture("ambiguous-refs") + + self.assertEqual(result.returncode, 0, result.stderr) + refs_updates = [ + call + for call in calls + if call["method"] == "PUT" and "refs" in (call["body"] or {}) + ] + self.assertEqual(len(refs_updates), 2) + self.assertEqual(refs_updates[0]["body"], refs_updates[1]["body"]) + + def test_sentry_release_fails_loudly_when_ambiguous_create_cannot_be_reconciled(self) -> None: + result, calls = self.run_sentry_prepare_fixture("unknown-create") + + self.assertNotEqual(result.returncode, 0) + self.assertEqual(len([call for call in calls if call["method"] == "POST"]), 1) + self.assertIn("Unable to reconcile Sentry release state", result.stderr) + self.assertNotIn("fixture-token", result.stdout + result.stderr) + + def test_finalize_sentry_recovery_mode_accepts_an_existing_finalized_release(self) -> None: + result, calls = self.run_sentry_prepare_fixture("existing-finalized", action="recover") + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(any(call["method"] in {"POST", "PUT"} for call in calls)) + self.assertIn("already finalized", result.stdout) + + def test_finalize_sentry_recovery_mode_finalizes_an_existing_unfinalized_release(self) -> None: + result, calls = self.run_sentry_prepare_fixture("existing-unfinalized", action="recover") + + self.assertEqual(result.returncode, 0, result.stderr) + self.assertFalse(any(call["method"] == "POST" for call in calls)) + finalizations = [ + call + for call in calls + if call["method"] == "PUT" and "dateReleased" in (call["body"] or {}) + ] + self.assertEqual(len(finalizations), 1) + + def test_finalize_sentry_recovery_mode_requires_sentry_to_be_enabled(self) -> None: + result, calls = self.run_sentry_prepare_fixture("existing-unfinalized", action="recover-disabled") + + self.assertNotEqual(result.returncode, 0) + self.assertEqual(calls, []) + self.assertIn("finalize-sentry requires REPOPROMPT_ENABLE_SENTRY=1", result.stderr) + + def test_sentry_timeout_configuration_rejects_unbounded_values_before_network(self) -> None: + result, calls = self.run_sentry_prepare_fixture( + "not-found-once", + env_overrides={"REPOPROMPT_SENTRY_REQUEST_TIMEOUT_SECONDS": "301"}, + ) + + self.assertNotEqual(result.returncode, 0) + self.assertEqual(calls, []) + self.assertIn("must not exceed 300 seconds", result.stderr) + + def run_sentry_deploy_fixture(self, scenario: str) -> tuple[subprocess.CompletedProcess[str], list[dict[str, object]]]: + temp_dir = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, temp_dir, True) + call_log = temp_dir / "calls.jsonl" + counter = temp_dir / "counter" + state = temp_dir / "state" + fake_curl = temp_dir / "curl" + fake_curl.write_text( + """#!/usr/bin/env python3 +import json +import os +import stat +import sys +from pathlib import Path + +args = sys.argv[1:] +def option(name): + return args[args.index(name) + 1] + +if option("--connect-timeout") != "10" or option("--max-time") != "60": + raise SystemExit(90) +if "--retry" in args or "--retry-all-errors" in args: + raise SystemExit(91) +config = Path(option("--config")) +if stat.S_IMODE(config.stat().st_mode) != 0o600: + raise SystemExit(93) +if config.read_text(encoding="utf-8") != 'header = "Authorization: Bearer fixture-token"\\n': + raise SystemExit(94) +if "SENTRY_AUTH_TOKEN" in os.environ: + raise SystemExit(95) +method = option("--request") +output = Path(option("--output")) +body = None +if "--data-binary" in args: + body = json.loads(Path(option("--data-binary")[1:]).read_text(encoding="utf-8")) +with Path(os.environ["CALL_LOG"]).open("a", encoding="utf-8") as handle: + handle.write(json.dumps({"method": method, "body": body}) + "\\n") + +state = Path(os.environ["DEPLOY_STATE"]) +counter = Path(os.environ["DEPLOY_COUNTER"]) +if method == "GET": + response = ([{"environment": "production", "name": "vfixture"}] if state.exists() else []) + status = 200 +elif method == "POST": + attempt = int(counter.read_text(encoding="utf-8")) + 1 if counter.exists() else 1 + counter.write_text(str(attempt), encoding="utf-8") + if os.environ["SCENARIO"] == "lost" and attempt == 1: + raise SystemExit(28) + state.write_text("landed", encoding="utf-8") + if os.environ["SCENARIO"] == "landed" and attempt == 1: + raise SystemExit(28) + response = {"environment": "production", "name": "vfixture"} + status = 201 +else: + raise SystemExit(92) +output.write_text(json.dumps(response), encoding="utf-8") +sys.stdout.write(str(status)) +""", + encoding="utf-8", + ) + fake_curl.chmod(0o755) + api_tmp = temp_dir / "api" + api_tmp.mkdir() + env = os.environ.copy() + env.update( + { + "PATH": f"{temp_dir}:{env.get('PATH', '')}", + "SENTRY_AUTH_TOKEN": "fixture-token", + "REPOPROMPT_SENTRY_ORG": "fixture-org", + "REPOPROMPT_SENTRY_PROJECT": "fixture-project", + "REPOPROMPT_SENTRY_DEPLOY_ENVIRONMENT": "production", + "RELEASE_TAG": "vfixture", + "FIXTURE_TMP_DIR": str(api_tmp), + "CALL_LOG": str(call_log), + "DEPLOY_STATE": str(state), + "DEPLOY_COUNTER": str(counter), + "SCENARIO": scenario, + } + ) + result = subprocess.run( + [ + "bash", + "-c", + 'source "$1"; TMP_DIR="$FIXTURE_TMP_DIR"; preflight_sentry_deploy_access; record_verified_sentry_deploy_if_needed', + "sentry-deploy-test", + str(SCRIPT_DIR / "promote_release.sh"), + ], + env=env, + text=True, + capture_output=True, + ) + calls = [json.loads(line) for line in call_log.read_text(encoding="utf-8").splitlines()] + return result, calls + + def test_sentry_deploy_recovers_ambiguous_create_outcomes_without_curl_retry(self) -> None: + for scenario, expected_posts in (("landed", 1), ("lost", 2)): + with self.subTest(scenario=scenario): + result, calls = self.run_sentry_deploy_fixture(scenario) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual(len([call for call in calls if call["method"] == "POST"]), expected_posts) + self.assertNotIn("fixture-token", result.stdout + result.stderr + json.dumps(calls)) + + def test_promotion_anonymous_downloads_have_bounded_retry_time(self) -> None: + temp_dir = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, temp_dir, True) + args_file = temp_dir / "args.json" + fake_curl = temp_dir / "curl" + fake_curl.write_text( + """#!/usr/bin/env python3 +import json +import os +import sys +from pathlib import Path +Path(os.environ["ARGS_FILE"]).write_text(json.dumps(sys.argv[1:]), encoding="utf-8") +""", + encoding="utf-8", + ) + fake_curl.chmod(0o755) + env = os.environ.copy() + env.update({"PATH": f"{temp_dir}:{env.get('PATH', '')}", "ARGS_FILE": str(args_file)}) + result = subprocess.run( + ["bash", "-c", 'source "$1"; curl_anonymous https://example.invalid/artifact', "download-test", str(SCRIPT_DIR / "promote_release.sh")], + env=env, + text=True, + capture_output=True, + ) + self.assertEqual(result.returncode, 0, result.stderr) + args = json.loads(args_file.read_text(encoding="utf-8")) + self.assertEqual(args[args.index("--connect-timeout") + 1], "10") + self.assertEqual(args[args.index("--max-time") + 1], "120") + self.assertEqual(args[args.index("--retry-max-time") + 1], "600") + def test_sentry_release_preflight_distinguishes_invalid_token_without_mutation(self) -> None: result, calls = self.run_sentry_prepare_fixture("unauthorized") @@ -1468,6 +1751,14 @@ def test_sentry_release_preflight_rejects_malformed_json_before_mutation(self) - self.assertFalse(any(call["method"] in {"POST", "PUT"} for call in calls)) self.assertIn("malformed JSON during access preflight", result.stderr) + def test_sentry_release_preflight_reports_transport_deadline_failure_clearly(self) -> None: + result, calls = self.run_sentry_prepare_fixture("transport") + + self.assertNotEqual(result.returncode, 0) + self.assertEqual(calls, []) + self.assertIn("configured network deadline", result.stderr) + self.assertNotIn("HTTP transport:", result.stderr) + def test_sentry_symbol_flow_is_explicit_secret_safe_and_release_only_by_default(self) -> None: package_script = (SCRIPT_DIR / "package_app.sh").read_text(encoding="utf-8") universal_builder = (SCRIPT_DIR / "build_swiftpm_release_products.sh").read_text(encoding="utf-8") @@ -1499,6 +1790,8 @@ def test_sentry_symbol_flow_is_explicit_secret_safe_and_release_only_by_default( self.assertIn('sentry_api_request PUT', release_script) self.assertIn("'{refs: [{repository: $repository, commit: $commit}]}'", release_script) self.assertIn('finalize_sentry_release', release_script) + self.assertIn('finalize-sentry) recover_sentry_finalization', release_script) + self.assertIn('Refusing to repeat publish-staged', release_script) self.assertIn("'{dateReleased: $date_released}'", release_script) self.assertNotIn('sentry-cli --org', release_script) self.assertNotIn('record_sentry_production_deploy', release_script) @@ -1828,10 +2121,13 @@ def _install_fake_swiftformat_download_tools( fake_curl.write_text( """#!/usr/bin/env python3 import os +import json import shutil import sys args = sys.argv[1:] +with open(os.environ["FAKE_SWIFTFORMAT_CURL_ARGS"], "w", encoding="utf-8") as handle: + json.dump(args, handle) output = args[args.index("--output") + 1] shutil.copyfile(os.environ["FAKE_SWIFTFORMAT_ARCHIVE"], output) """, @@ -1887,6 +2183,7 @@ def test_format_tool_install_verifies_and_resolves_managed_swiftformat(self) -> ) self._install_fake_swiftformat_download_tools(root, archive, pinned_checksum) env["FAKE_SWIFTFORMAT_ARCHIVE"] = str(archive) + env["FAKE_SWIFTFORMAT_CURL_ARGS"] = str(root / "curl-args.json") installed = subprocess.run( [str(installer), "install"], @@ -1898,6 +2195,10 @@ def test_format_tool_install_verifies_and_resolves_managed_swiftformat(self) -> self.assertIn(f"Installed SwiftFormat 0.61.1 at {managed_swiftformat}", installed.stdout) self.assertTrue(os.access(managed_swiftformat, os.X_OK)) self.assertFalse(mismatched_invocations.exists()) + curl_args = json.loads((root / "curl-args.json").read_text(encoding="utf-8")) + self.assertEqual(curl_args[curl_args.index("--connect-timeout") + 1], "10") + self.assertEqual(curl_args[curl_args.index("--max-time") + 1], "120") + self.assertEqual(curl_args[curl_args.index("--retry-max-time") + 1], "300") resolved = subprocess.run( [str(installer), "resolve-swiftformat"], @@ -1918,6 +2219,7 @@ def test_format_tool_install_rejects_bad_swiftformat_checksum(self) -> None: archive.write_bytes(b"not-the-official-swiftformat-archive") self._install_fake_swiftformat_download_tools(root, archive, "0" * 64) env["FAKE_SWIFTFORMAT_ARCHIVE"] = str(archive) + env["FAKE_SWIFTFORMAT_CURL_ARGS"] = str(root / "curl-args.json") result = subprocess.run( [str(installer), "install"], @@ -1930,6 +2232,30 @@ def test_format_tool_install_rejects_bad_swiftformat_checksum(self) -> None: self.assertFalse(managed_swiftformat.exists()) self.assertFalse(mismatched_invocations.exists()) + def test_format_tool_install_rejects_unbounded_download_timeout_before_curl(self) -> None: + installer = SCRIPT_DIR / "install_format_tools.sh" + root, env, _ = self._make_format_tools_test_environment("0.62.1") + archive = root / "fixtures" / "swiftformat.zip" + self._install_fake_swiftformat_download_tools(root, archive, "0" * 64) + curl_args = root / "curl-args.json" + env.update( + { + "FAKE_SWIFTFORMAT_ARCHIVE": str(archive), + "FAKE_SWIFTFORMAT_CURL_ARGS": str(curl_args), + "REPOPROMPT_FORMAT_DOWNLOAD_RETRY_TIMEOUT_SECONDS": "601", + } + ) + + result = subprocess.run( + [str(installer), "install"], + env=env, + text=True, + capture_output=True, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("must not exceed 600 seconds", result.stderr) + self.assertFalse(curl_args.exists()) + def test_swift_style_never_formats_with_mismatched_path_swiftformat(self) -> None: style_script = SCRIPT_DIR / "swift_style.sh" _, env, mismatched_invocations = self._make_format_tools_test_environment("0.62.1")