Last active
May 29, 2025 07:25
-
-
Save kekyo/680809245c73f7a46266bf459cf0ef8c to your computer and use it in GitHub Desktop.
Simple NuGet package publisher using curl instead of `dotnet nuget push`.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # curl-nuget-push.sh | |
| # Alternative curl script for dotnet nuget push | |
| # May 29, 2025 | |
| # License: CC0 | |
| # https://gist.github.com/kekyo/680809245c73f7a46266bf459cf0ef8c | |
| set -euo pipefail | |
| # Default settings | |
| DEFAULT_SERVER="https://api.nuget.org" | |
| DEFAULT_TIMEOUT=300 | |
| DEFAULT_PROTOCOL="http1.1" | |
| DEFAULT_API_VERSION="auto" | |
| # Show usage | |
| show_usage() { | |
| cat << EOF | |
| Usage: $0 <package.nupkg> <api-key> [options] | |
| Arguments: | |
| package.nupkg NuGet package file | |
| api-key API authentication key | |
| Options: | |
| -s, --server URL NuGet server endpoint URL (default: $DEFAULT_SERVER) | |
| -t, --timeout SEC Timeout in seconds (default: $DEFAULT_TIMEOUT) | |
| -p, --protocol PROTO HTTP protocol (http1.1|http2) (default: $DEFAULT_PROTOCOL) | |
| -a, --api-version VER API version (v2|v3|auto) (default: $DEFAULT_API_VERSION) | |
| -v, --verbose Verbose output | |
| -h, --help Show this help | |
| Examples: | |
| $0 MyPackage.1.0.0.nupkg "MY-API-KEY" | |
| $0 MyPackage.1.0.0.nupkg "MY-API-KEY" --server "https://package.example.com/api/v2/package" | |
| $0 MyPackage.1.0.0.nupkg "MY-API-KEY" --server "https://nexus.example.com/repository/nuget" --api-version v3 | |
| $0 MyPackage.1.0.0.nupkg "MY-API-KEY" --protocol http1.1 --verbose | |
| EOF | |
| } | |
| # Log functions | |
| log_info() { | |
| echo "INFO: $*" >&2 | |
| } | |
| log_success() { | |
| echo "SUCCESS: $*" >&2 | |
| } | |
| log_warning() { | |
| echo "WARNING: $*" >&2 | |
| } | |
| log_error() { | |
| echo "ERROR: $*" >&2 | |
| } | |
| # NuGet v3 Push multipart form creation | |
| create_multipart_form() { | |
| local package_file="$1" | |
| local temp_form_file="$2" | |
| local boundary="----FormBoundary$(date +%s)$(shuf -i 1000-9999 -n 1)" | |
| { | |
| echo "--$boundary" | |
| echo "Content-Disposition: form-data; name=\"package\"; filename=\"$(basename "$package_file")\"" | |
| echo "Content-Type: application/octet-stream" | |
| echo "" | |
| cat "$package_file" | |
| echo "" | |
| echo "--$boundary--" | |
| } > "$temp_form_file" | |
| echo "$boundary" | |
| } | |
| # Parameter parsing | |
| PACKAGE_FILE="" | |
| API_KEY="" | |
| SERVER_URL="$DEFAULT_SERVER" | |
| TIMEOUT="$DEFAULT_TIMEOUT" | |
| PROTOCOL="$DEFAULT_PROTOCOL" | |
| API_VERSION="$DEFAULT_API_VERSION" | |
| VERBOSE=false | |
| while [[ $# -gt 0 ]]; do | |
| case $1 in | |
| -s|--server) | |
| SERVER_URL="$2" | |
| shift 2 | |
| ;; | |
| -t|--timeout) | |
| TIMEOUT="$2" | |
| shift 2 | |
| ;; | |
| -p|--protocol) | |
| PROTOCOL="$2" | |
| shift 2 | |
| ;; | |
| -a|--api-version) | |
| API_VERSION="$2" | |
| shift 2 | |
| ;; | |
| -v|--verbose) | |
| VERBOSE=true | |
| shift | |
| ;; | |
| -h|--help) | |
| show_usage | |
| exit 0 | |
| ;; | |
| -*) | |
| log_error "Unknown option: $1" | |
| show_usage | |
| exit 1 | |
| ;; | |
| *) | |
| if [[ -z "$PACKAGE_FILE" ]]; then | |
| PACKAGE_FILE="$1" | |
| elif [[ -z "$API_KEY" ]]; then | |
| API_KEY="$1" | |
| else | |
| log_error "Too many arguments" | |
| show_usage | |
| exit 1 | |
| fi | |
| shift | |
| ;; | |
| esac | |
| done | |
| # Required parameter check | |
| if [[ -z "$PACKAGE_FILE" ]] || [[ -z "$API_KEY" ]]; then | |
| log_error "Missing required arguments" | |
| show_usage | |
| exit 1 | |
| fi | |
| # File existence check | |
| if [[ ! -f "$PACKAGE_FILE" ]]; then | |
| log_error "Package file not found: $PACKAGE_FILE" | |
| exit 1 | |
| fi | |
| # Protocol configuration | |
| case "$PROTOCOL" in | |
| http1.1) | |
| PROTOCOL_OPTION="--http1.1" | |
| ;; | |
| http2) | |
| PROTOCOL_OPTION="--http2" | |
| ;; | |
| *) | |
| log_error "Invalid protocol: $PROTOCOL (use http1.1 or http2)" | |
| exit 1 | |
| ;; | |
| esac | |
| # API version configuration | |
| DETECTED_API_VERSION="" | |
| case "$API_VERSION" in | |
| v2) | |
| DETECTED_API_VERSION="v2" | |
| ;; | |
| v3) | |
| DETECTED_API_VERSION="v3" | |
| ;; | |
| auto) | |
| # Auto-detect based on server URL patterns | |
| if [[ "$SERVER_URL" == */repository/nuget* ]] || [[ "$SERVER_URL" == */v3/* ]] || [[ "$SERVER_URL" == */api/v3* ]]; then | |
| DETECTED_API_VERSION="v3" | |
| else | |
| DETECTED_API_VERSION="v2" | |
| fi | |
| ;; | |
| *) | |
| log_error "Invalid API version: $API_VERSION (use v2, v3, or auto)" | |
| exit 1 | |
| ;; | |
| esac | |
| # Use server URL as-is (no automatic path modification) | |
| ENDPOINT="$SERVER_URL" | |
| # Temporary files | |
| RESPONSE_FILE=$(mktemp) | |
| HEADERS_FILE=$(mktemp) | |
| FORM_FILE="" | |
| if [[ "$DETECTED_API_VERSION" == "v3" ]]; then | |
| FORM_FILE=$(mktemp) | |
| fi | |
| # Cleanup function | |
| cleanup() { | |
| rm -f "$RESPONSE_FILE" "$HEADERS_FILE" "$FORM_FILE" | |
| } | |
| trap cleanup EXIT | |
| # Display package information | |
| PACKAGE_SIZE=$(stat -c%s "$PACKAGE_FILE" 2>/dev/null || stat -f%z "$PACKAGE_FILE" 2>/dev/null || echo "unknown") | |
| log_info "Package: $PACKAGE_FILE (${PACKAGE_SIZE} bytes)" | |
| log_info "Endpoint: $ENDPOINT" | |
| log_info "Protocol: $PROTOCOL" | |
| log_info "API Version: $DETECTED_API_VERSION" | |
| log_info "Timeout: ${TIMEOUT}s" | |
| # Execute curl | |
| log_info "Uploading package..." | |
| # Basic curl options | |
| CURL_OPTS=( | |
| "$PROTOCOL_OPTION" | |
| -H "X-NuGet-ApiKey: $API_KEY" | |
| -H "User-Agent: curl-nuget-client/1.0" | |
| --max-time "$TIMEOUT" | |
| --connect-timeout 30 | |
| -w '%{http_code}' | |
| -o "$RESPONSE_FILE" | |
| -D "$HEADERS_FILE" | |
| -s | |
| ) | |
| if [[ "$VERBOSE" == true ]]; then | |
| CURL_OPTS+=(-v) | |
| fi | |
| # API version specific processing | |
| if [[ "$DETECTED_API_VERSION" == "v3" ]]; then | |
| # NuGet v3 API: multipart/form-data | |
| boundary=$(create_multipart_form "$PACKAGE_FILE" "$FORM_FILE") | |
| CURL_OPTS+=( | |
| -X POST | |
| -H "Content-Type: multipart/form-data; boundary=$boundary" | |
| --data-binary "@$FORM_FILE" | |
| ) | |
| log_info "Using NuGet v3 API (multipart form)" | |
| else | |
| # NuGet v2 API: application/octet-stream | |
| CURL_OPTS+=( | |
| -X PUT | |
| -H "Content-Type: application/octet-stream" | |
| --data-binary "@$PACKAGE_FILE" | |
| ) | |
| log_info "Using NuGet v2 API (binary stream)" | |
| fi | |
| HTTP_CODE=$(curl "${CURL_OPTS[@]}" "$ENDPOINT") | |
| CURL_EXIT_CODE=$? | |
| # Check curl execution result | |
| if [[ $CURL_EXIT_CODE -ne 0 ]]; then | |
| log_error "curl failed with exit code $CURL_EXIT_CODE" | |
| case $CURL_EXIT_CODE in | |
| 6) | |
| log_error "Could not resolve host" | |
| ;; | |
| 7) | |
| log_error "Failed to connect to host" | |
| ;; | |
| 28) | |
| log_error "Operation timeout" | |
| ;; | |
| *) | |
| log_error "curl error (see man curl for details)" | |
| ;; | |
| esac | |
| exit 1 | |
| fi | |
| # Get response details | |
| RESPONSE_BODY="" | |
| if [[ -f "$RESPONSE_FILE" ]]; then | |
| RESPONSE_BODY=$(cat "$RESPONSE_FILE") | |
| fi | |
| SERVER_HEADER="" | |
| if [[ -f "$HEADERS_FILE" ]]; then | |
| SERVER_HEADER=$(grep -i "^server:" "$HEADERS_FILE" 2>/dev/null | cut -d' ' -f2- | tr -d '\r\n' || echo "unknown") | |
| fi | |
| # Result evaluation | |
| case "$HTTP_CODE" in | |
| 200|201|202) | |
| log_success "Package uploaded successfully (HTTP $HTTP_CODE)" | |
| if [[ "$VERBOSE" == true ]]; then | |
| log_info "Server: $SERVER_HEADER" | |
| log_info "API Version: $DETECTED_API_VERSION" | |
| fi | |
| exit 0 | |
| ;; | |
| 400) | |
| log_error "Bad Request (HTTP 400)" | |
| if [[ -n "$RESPONSE_BODY" ]]; then | |
| echo "Response: $RESPONSE_BODY" >&2 | |
| fi | |
| # Suggest using v3 API if multipart required error is detected | |
| if [[ "$RESPONSE_BODY" == *"Multipart request required"* ]] && [[ "$DETECTED_API_VERSION" == "v2" ]]; then | |
| log_warning "Server requires multipart format, but using v2 endpoint" | |
| log_info "Try running with --api-version v3" | |
| fi | |
| exit 1 | |
| ;; | |
| 401) | |
| log_error "Unauthorized (HTTP 401)" | |
| log_error "Check your API key" | |
| exit 1 | |
| ;; | |
| 403) | |
| log_error "Forbidden (HTTP 403)" | |
| log_error "Insufficient permissions" | |
| exit 1 | |
| ;; | |
| 409) | |
| log_warning "Package already exists (HTTP 409)" | |
| log_info "Package version already published" | |
| exit 0 | |
| ;; | |
| 413) | |
| log_error "Package too large (HTTP 413)" | |
| log_error "Package size: ${PACKAGE_SIZE} bytes" | |
| exit 1 | |
| ;; | |
| 500|502|503|504) | |
| log_error "Server error (HTTP $HTTP_CODE)" | |
| log_error "Try again later" | |
| exit 1 | |
| ;; | |
| *) | |
| log_error "Unexpected response (HTTP $HTTP_CODE)" | |
| if [[ -n "$RESPONSE_BODY" ]]; then | |
| echo "Response: $RESPONSE_BODY" >&2 | |
| fi | |
| exit 1 | |
| ;; | |
| esac |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment