#!/usr/bin/env bash # .NET MAUI PR Build Applicator (Bash version) # # This script downloads and applies NuGet packages from a specific .NET MAUI pull request build # to your local project. It automatically detects your project's target framework and updates # the necessary package references. # # The script uses a hive-based approach, storing packages in: ~/.maui/hives/pr-/packages # # Usage: # curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 33002 # ./get-maui-pr.sh [-y|--yes] [PROJECT_PATH] # # Examples: # ./get-maui-pr.sh 33002 # ./get-maui-pr.sh 33002 ./MyApp/MyApp.csproj # ./get-maui-pr.sh -y 33002 # Skip confirmation prompts # # Requirements: # - .NET SDK installed # - curl and jq installed # - unzip installed # - Internet connection to access GitHub and Azure DevOps APIs # - A valid .NET MAUI project # # Repository Override: # Set MAUI_REPO environment variable to point to a fork (e.g., 'myfork/maui') # # For more information about testing PR builds, visit: # https://github.com/dotnet/maui/wiki/Testing-PR-Builds set -e # Error handler trap 'handle_error $? $LINENO' ERR handle_error() { local exit_code=$1 local line_num=$2 echo "" error "Failed to apply PR build (exit code: $exit_code at line $line_num)" echo "" info "Troubleshooting tips:" echo " • Make sure you're in a directory containing a .NET MAUI project" echo " • Verify that PR #${pr_number:-NUMBER} exists: https://github.com/dotnet/maui/pull/${pr_number:-NUMBER}" echo " • Check if there's a completed maui-pr build with PackageArtifacts for this PR" echo " • Check your internet connection" echo " • Visit: https://github.com/dotnet/maui/wiki/Testing-PR-Builds" exit $exit_code } # Configuration - Allow override via environment variable GITHUB_REPO="${MAUI_REPO:-dotnet/maui}" AZURE_DEVOPS_ORG="dnceng-public" AZURE_DEVOPS_PROJECT="public" PACKAGE_NAME="Microsoft.Maui.Controls" # Build GitHub auth header if token available (GITHUB_TOKEN or gh CLI) GITHUB_AUTH_HEADER="" if [ -n "$GITHUB_TOKEN" ]; then GITHUB_AUTH_HEADER="Authorization: token $GITHUB_TOKEN" elif command -v gh &> /dev/null && gh auth status &> /dev/null; then GITHUB_TOKEN=$(gh auth token 2>/dev/null) if [ -n "$GITHUB_TOKEN" ]; then GITHUB_AUTH_HEADER="Authorization: token $GITHUB_TOKEN" fi fi # Colors RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' CYAN='\033[0;36m' MAGENTA='\033[0;35m' WHITE='\033[1;37m' GRAY='\033[0;37m' DGRAY='\033[0;90m' NC='\033[0m' # No Color # Output functions info() { echo -e "${CYAN}ℹ️ $1${NC}" >&2 } success() { echo -e "${GREEN}✅ $1${NC}" >&2 } warning() { echo -e "${YELLOW}⚠️ $1${NC}" >&2 } error() { echo -e "${RED}❌ $1${NC}" >&2 } step() { echo -e "\n${BLUE}▶️ $1${NC}" >&2 } # Check dependencies check_dependencies() { local missing_deps=() if ! command -v curl &> /dev/null; then missing_deps+=("curl") fi if ! command -v jq &> /dev/null; then missing_deps+=("jq") fi if ! command -v unzip &> /dev/null; then missing_deps+=("unzip") fi if ! command -v dotnet &> /dev/null; then missing_deps+=("dotnet") fi if [ ${#missing_deps[@]} -gt 0 ]; then error "Missing required dependencies: ${missing_deps[*]}" echo "" info "Please install the missing dependencies:" for dep in "${missing_deps[@]}"; do echo " - $dep" done exit 1 fi } # Find MAUI project find_maui_project() { local search_path="$1" if [ -z "$search_path" ]; then search_path="." fi # If it's a file and ends with .csproj if [ -f "$search_path" ] && [[ "$search_path" == *.csproj ]]; then echo "$search_path" return 0 fi # Search for .csproj files with UseMaui for proj in "$search_path"/*.csproj; do if [ -f "$proj" ]; then if grep -q 'true' "$proj"; then echo "$proj" return 0 fi fi done error "No .NET MAUI project found in $search_path" info "Make sure you're in a directory containing a MAUI project (.csproj with true)" exit 1 } # Get PR information from GitHub get_pr_info() { local pr_number="$1" info "Fetching PR #$pr_number information from GitHub..." local pr_url="https://api.github.com/repos/$GITHUB_REPO/pulls/$pr_number" local pr_json pr_json=$(curl -s -H "User-Agent: MAUI-PR-Script" ${GITHUB_AUTH_HEADER:+-H "$GITHUB_AUTH_HEADER"} "$pr_url") if [ -z "$pr_json" ] || echo "$pr_json" | jq -e '.message' > /dev/null 2>&1; then error "Failed to fetch PR information. Make sure PR #$pr_number exists." exit 1 fi echo "$pr_json" } # Check if a build is currently in progress for this PR via Azure DevOps API check_build_in_progress() { local pr_num="$1" local builds_url="https://dev.azure.com/$AZURE_DEVOPS_ORG/$AZURE_DEVOPS_PROJECT/_apis/build/builds?api-version=7.1&branchName=refs/pull/$pr_num/merge&\$top=10" local builds_json builds_json=$(curl -s -H "User-Agent: MAUI-PR-Script" "$builds_url" 2>/dev/null) || return 1 # Check if any maui-pr build is in progress local in_progress in_progress=$(echo "$builds_json" | jq -r '[.value[] | select(.definition.name == "maui-pr" and (.status == "inProgress" or .status == "notStarted" or .status == "postponed"))] | length' 2>/dev/null || echo "0") if [ "$in_progress" != "0" ] && [ -n "$in_progress" ]; then return 0 # true - build is in progress fi return 1 # false - no build in progress } # Get build information from GitHub Checks API, with AzDO fallback get_build_info() { local sha="$1" local pr_num="$2" local merge_sha="${3:-}" info "Looking for build artifacts for commit ${sha:0:7}..." # Strategy 1: Try GitHub Checks API local checks_url="https://api.github.com/repos/$GITHUB_REPO/commits/$sha/check-runs" local checks_json checks_json=$(curl -s -H "User-Agent: MAUI-PR-Script" -H "Accept: application/vnd.github.v3+json" ${GITHUB_AUTH_HEADER:+-H "$GITHUB_AUTH_HEADER"} "$checks_url") # Find the aggregate MAUI PR build check. Job-level checks share the # maui-pr prefix and can point at the same build with a different result. local build_check=$(echo "$checks_json" | jq -r '.check_runs[]? | select(.name == "maui-pr" and .status == "completed" and (.details_url | contains("buildId="))) | @json' | head -n 1) if [ -n "$build_check" ] && [ "$build_check" != "null" ]; then local conclusion=$(echo "$build_check" | jq -r '.conclusion') if [ "$conclusion" != "success" ]; then warning "The aggregate maui-pr build completed with status: $conclusion" warning "Continuing because PackageArtifacts may still be available when unrelated CI legs fail." fi # Extract build ID from details URL local details_url=$(echo "$build_check" | jq -r '.details_url') if [[ "$details_url" =~ buildId=([0-9]+) ]]; then local build_id="${BASH_REMATCH[1]}" success "Found build ID: $build_id (via GitHub Checks)" echo "$build_id" return 0 fi fi # Strategy 2: Query Azure DevOps directly (handles merge commits not reported to GitHub) info "Searching Azure DevOps directly for PR #$pr_num builds..." local builds_url="https://dev.azure.com/$AZURE_DEVOPS_ORG/$AZURE_DEVOPS_PROJECT/_apis/build/builds?api-version=7.1&branchName=refs/pull/$pr_num/merge&\$top=25" local builds_json builds_json=$(curl -s -H "User-Agent: MAUI-PR-Script" "$builds_url" 2>/dev/null) if [ -n "$builds_json" ]; then local completed_build completed_build=$(echo "$builds_json" | jq -r --arg head "$sha" --arg merge "$merge_sha" '[.value[] | select(.definition.name == "maui-pr" and .status == "completed" and (((.triggerInfo["pr.sourceSha"] // "") == $head) or ((.sourceVersion // "") == $head) or ($merge != "" and (.sourceVersion // "") == $merge)))] | first | @json' 2>/dev/null || echo "") if { [ -z "$completed_build" ] || [ "$completed_build" == "null" ]; } && echo "$builds_json" | jq -e '[.value[] | select(.definition.name == "maui-pr" and .status == "completed")] | length > 0' >/dev/null 2>&1; then warning "Found completed maui-pr builds for PR #$pr_num, but none match the current head/merge commit." fi if [ -n "$completed_build" ] && [ "$completed_build" != "null" ]; then local azdo_build_id=$(echo "$completed_build" | jq -r '.id') local azdo_result=$(echo "$completed_build" | jq -r '.result') # Validate build ID is numeric if ! [[ "$azdo_build_id" =~ ^[0-9]+$ ]]; then error "Invalid build ID received from Azure DevOps API" exit 1 fi # Check if a newer build is in progress (user may have pushed a new commit) local in_progress_count in_progress_count=$(echo "$builds_json" | jq -r '[.value[] | select(.definition.name == "maui-pr" and (.status == "inProgress" or .status == "notStarted" or .status == "postponed"))] | length' 2>/dev/null || echo "0") if [ "$in_progress_count" != "0" ] && [ -n "$in_progress_count" ]; then warning "A newer build is currently in progress. The available artifacts may be from a previous commit." warning "If you just pushed changes, wait for the new build to complete." fi if [ "$azdo_result" != "succeeded" ]; then warning "The aggregate maui-pr build completed with result: $azdo_result" warning "Continuing because PackageArtifacts may still be available when unrelated CI legs fail." fi success "Found build ID: $azdo_build_id (via Azure DevOps)" echo "$azdo_build_id" return 0 fi fi # No build found - check if one is in progress if check_build_in_progress "$pr_num"; then error "No completed build found, but a build is currently in progress for PR #$pr_num" info "Please wait for it to complete and try again." info "Check status: https://github.com/dotnet/maui/pull/$pr_num" exit 1 fi error "No completed build found for PR #$pr_num" info "The PR may not have triggered CI builds yet (draft PRs don't auto-trigger builds), or the build may have failed." info "Check: https://github.com/dotnet/maui/pull/$pr_num" exit 1 } check_pack_job_status() { local build_id="$1" local timeline_url="https://dev.azure.com/$AZURE_DEVOPS_ORG/$AZURE_DEVOPS_PROJECT/_apis/build/builds/$build_id/timeline?api-version=7.1" local timeline_json if ! timeline_json=$(curl -s -H "User-Agent: MAUI-PR-Script" "$timeline_url" 2>/dev/null); then warning "Could not verify pack job status from the Azure DevOps timeline." return 0 fi local pack_count pack_count=$(echo "$timeline_json" | jq -r '[.records[]? | select((.type == "Job" or .type == "Phase") and (.name == "Pack macOS" or .name == "Pack Windows"))] | length' 2>/dev/null || echo "0") if [ "$pack_count" == "0" ] || [ -z "$pack_count" ]; then warning "Could not verify pack job status from the Azure DevOps timeline." return 0 fi local non_succeeded non_succeeded=$(echo "$timeline_json" | jq -r '.records[]? | select((.type == "Job" or .type == "Phase") and (.name == "Pack macOS" or .name == "Pack Windows") and .result != "succeeded") | "\(.name) (\(.type)): \(.result // .state // "unknown")"' 2>/dev/null || echo "") if [ -n "$non_succeeded" ]; then warning "PackageArtifacts exists, but one or more pack/package-producing jobs did not report success:" while IFS= read -r record; do [ -n "$record" ] && warning " $record" done <<< "$non_succeeded" else info "Verified pack/package-producing jobs succeeded." fi } # Get artifacts from Azure DevOps get_build_artifacts() { local build_id="$1" info "Fetching artifacts from Azure DevOps build $build_id..." local artifacts_url="https://dev.azure.com/$AZURE_DEVOPS_ORG/$AZURE_DEVOPS_PROJECT/_apis/build/builds/$build_id/artifacts?api-version=7.1" local artifacts_json=$(curl -s -H "User-Agent: MAUI-PR-Script" "$artifacts_url") # Look for PackageArtifacts artifact local download_url=$(echo "$artifacts_json" | jq -r '.value[] | select(.name == "PackageArtifacts") | .resource.downloadUrl' | head -n 1) if [ -z "$download_url" ] || [ "$download_url" == "null" ]; then error "No 'PackageArtifacts' artifact found in build $build_id" exit 1 fi check_pack_job_status "$build_id" echo "$download_url" } # Download and extract artifacts get_artifacts() { local download_url="$1" local build_id="$2" # Use hive directory pattern like Aspire CLI local hive_dir="$HOME/.maui/hives/pr-$pr_number" local packages_dir="$hive_dir/packages" local temp_dir="$hive_dir" local zip_file="$temp_dir/artifacts.zip" local extract_dir="$packages_dir" if [ -d "$temp_dir" ]; then info "Cleaning up previous download..." rm -rf "$temp_dir" fi mkdir -p "$temp_dir" mkdir -p "$extract_dir" info "Downloading artifacts (this may take a moment)..." curl -L -o "$zip_file" "$download_url" 2>/dev/null success "Downloaded artifacts" info "Extracting artifacts..." unzip -q "$zip_file" -d "$extract_dir" # Find the NuGet packages directory local nupkg_dir=$(find "$extract_dir" -type f -name "*.nupkg" -not -name "*.symbols.nupkg" | head -n 1 | xargs dirname) if [ -z "$nupkg_dir" ]; then error "Could not find NuGet packages in the extracted artifacts" exit 1 fi # Clean up zip file to save disk space rm -f "$zip_file" echo "$nupkg_dir" } # Get package version from directory get_package_version() { local packages_dir="$1" local package_file=$(find "$packages_dir" -type f -name "$PACKAGE_NAME.*.nupkg" -not -name "*.symbols.nupkg" | grep -E "$PACKAGE_NAME\.[0-9]" | head -n 1) if [ -z "$package_file" ]; then error "Could not find $PACKAGE_NAME package in artifacts" exit 1 fi local filename=$(basename "$package_file") if [[ "$filename" =~ $PACKAGE_NAME\.(.+)\.nupkg ]]; then echo "${BASH_REMATCH[1]}" return 0 fi error "Could not extract version from package filename: $filename" exit 1 } # Detect target framework version get_target_framework_version() { local project_path="$1" local content=$(cat "$project_path") if [[ "$content" =~ \([^\<]+)\ ]]; then local tfms="${BASH_REMATCH[1]}" if [[ "$tfms" =~ net([0-9]+)\.0 ]]; then echo "${BASH_REMATCH[1]}" return 0 fi fi error "Could not determine target framework version from project file" exit 1 } # Extract .NET version from package version get_package_dotnet_version() { local version="$1" # Extract major version from package (e.g., "10.0.20-ci..." -> 10) if [[ "$version" =~ ^([0-9]+)\. ]]; then echo "${BASH_REMATCH[1]}" return 0 fi # Default to current stable if can't determine echo "9" } # Check if version matches target framework test_version_compatibility() { local version="$1" local target_net_version="$2" local package_net_version="$3" if [[ "$version" =~ preview|ci\. ]]; then if [ "$target_net_version" -lt "$package_net_version" ]; then return 1 fi fi return 0 } # Update target frameworks update_target_frameworks() { local project_path="$1" local new_net_version="$2" # Create a backup cp "$project_path" "$project_path.bak" # Update all netX.0-* references (including in conditional TargetFrameworks) sed -i.tmp -E "s/net[0-9]+\.0-/net${new_net_version}.0-/g" "$project_path" rm -f "$project_path.tmp" success "Updated target frameworks to .NET $new_net_version.0" warning "You may need to update other package dependencies to match .NET $new_net_version.0" } # Create or update NuGet.config update_nuget_config() { local project_dir="$1" local packages_dir="$2" local nuget_config="$project_dir/NuGet.config" local source_name="maui-pr-$pr_number" if [ -f "$nuget_config" ]; then info "Updating existing NuGet.config..." # Remove existing source with same name if it exists sed -i.tmp -E "/| \n |" "$nuget_config" rm -f "$nuget_config.tmp" else info "Creating new NuGet.config..." cat > "$nuget_config" < EOF fi success "NuGet.config configured with local package source" } # Update project package reference update_package_reference() { local project_path="$1" local version="$2" # Create a backup cp "$project_path" "$project_path.bak" local content=$(cat "$project_path") # Check if using $(MauiVersion) variable if [[ "$content" =~ \ [PROJECT_PATH]" exit 1 fi pr_number="${positional_args[0]}" # Global for error handler # Validate PR number is numeric if ! [[ "$pr_number" =~ ^[0-9]+$ ]]; then error "PR number must be a valid number, got: $pr_number" exit 1 fi local project_path_arg="${positional_args[1]:-}" # Check dependencies check_dependencies # Display banner echo -e "${MAGENTA}" cat << "EOF" ╔═══════════════════════════════════════════════════════════╗ ║ ║ ║ .NET MAUI PR Build Applicator ║ ║ ║ ╚═══════════════════════════════════════════════════════════╝ EOF echo -e "${NC}" step "Finding MAUI project" local project_path project_path=$(find_maui_project "$project_path_arg") local project_dir project_dir=$(dirname "$project_path") local project_name project_name=$(basename "$project_path") success "Found project: $project_name" step "Fetching PR information" local pr_json pr_json=$(get_pr_info "$pr_number") local pr_title pr_title=$(echo "$pr_json" | jq -r '.title') local pr_state pr_state=$(echo "$pr_json" | jq -r '.state') local pr_sha pr_sha=$(echo "$pr_json" | jq -r '.head.sha') local pr_merge_sha pr_merge_sha=$(echo "$pr_json" | jq -r '.merge_commit_sha // ""') info "PR #$pr_number: $pr_title" info "State: $pr_state" step "Detecting target framework" local target_net_version target_net_version=$(get_target_framework_version "$project_path") info "Current target framework: .NET $target_net_version.0" step "Finding build artifacts" local build_id build_id=$(get_build_info "$pr_sha" "$pr_number" "$pr_merge_sha") step "Downloading artifacts" local download_url download_url=$(get_build_artifacts "$build_id") local packages_dir packages_dir=$(get_artifacts "$download_url" "$build_id") step "Extracting package information" local version version=$(get_package_version "$packages_dir") success "Found package version: $version" # Extract .NET version from package version (e.g., 10.0.20-ci.main.25607.5 -> 10) local package_dotnet_version="" if [[ $version =~ ^([0-9]+)\. ]]; then package_dotnet_version="${BASH_REMATCH[1]}" fi # Get package .NET version local package_net_version package_net_version=$(get_package_dotnet_version "$version") # Check compatibility local will_update_tfm=false local target_version="$package_net_version.0" if ! test_version_compatibility "$version" "$target_net_version" "$package_net_version"; then warning "This PR build may target a newer .NET version than your project" info "Your project targets: .NET $target_net_version.0" if [[ -n "$package_dotnet_version" ]]; then info "This PR build targets: .NET $package_dotnet_version.0" target_version="$package_dotnet_version.0" else info "This PR build targets: .NET $package_net_version.0" fi if [ "$YES_FLAG" = true ]; then will_update_tfm=true warning "Note: You may need to manually update other package dependencies to versions compatible with .NET $target_version" else read -p "Do you want to update your project to .NET $target_version? (y/N) " -n 1 -r echo if [[ $REPLY =~ ^[Yy]$ ]]; then will_update_tfm=true warning "Note: You may need to manually update other package dependencies to versions compatible with .NET $target_version" else warning "Continuing without updating target framework. The package may not be compatible." fi fi fi # Confirmation prompt echo "" echo -e "${YELLOW}═══════════════════════════════════════════════════════════${NC}" echo -e "${YELLOW} CONFIRMATION${NC}" echo -e "${YELLOW}═══════════════════════════════════════════════════════════${NC}" echo "" echo -e "${CYAN}By continuing, you will apply the PR artifacts to your project.${NC}" echo "" warning "This should NOT be used in production and is for testing purposes only." echo "" echo -e "${CYAN}TIP: Create a separate Git branch for testing!${NC}" echo -e "${GRAY} git checkout -b test-pr-$pr_number${NC}" echo "" echo -e "${WHITE}Please test the changes you are looking for, check for any side-effects,${NC}" echo -e "${WHITE}and report your findings on:${NC}" echo -e "${BLUE} https://github.com/dotnet/maui/pull/$pr_number${NC}" echo "" echo -e "${WHITE}Changes to be applied:${NC}" echo -e "${GRAY} • Project: $project_name${NC}" echo -e "${GRAY} • Package version: $version${NC}" if [ "$will_update_tfm" = true ]; then echo -e "${GRAY} • Target framework: Will be updated to .NET $target_version${NC}" fi echo "" if [ "$YES_FLAG" = true ]; then info "Auto-accepting confirmation (-y flag)" else read -p "Do you want to continue? (y/N) " -n 1 -r echo if [[ ! $REPLY =~ ^[Yy]$ ]]; then warning "Operation cancelled by user" exit 0 fi fi echo "" if [ "$will_update_tfm" = true ]; then local target_net_version_to_apply=10 if [[ -n "$package_dotnet_version" ]]; then target_net_version_to_apply="$package_dotnet_version" fi update_target_frameworks "$project_path" "$target_net_version_to_apply" target_net_version="$target_net_version_to_apply" fi step "Configuring NuGet sources" update_nuget_config "$project_dir" "$packages_dir" step "Updating package reference" update_package_reference "$project_path" "$version" echo -e "${GREEN}" cat << EOF ╔═══════════════════════════════════════════════════════════╗ ║ ║ ║ ✅ Successfully applied PR #$pr_number! ║ ║ ║ ╚═══════════════════════════════════════════════════════════╝ EOF echo -e "${NC}" info "Next steps:" echo " 1. Run 'dotnet restore' to download the packages" echo " 2. Build and test your project with the PR changes" echo -e " 3. Report your findings on: ${CYAN}https://github.com/dotnet/maui/pull/$pr_number${NC}" echo "" info "Package: $PACKAGE_NAME $version" info "Local package source: $packages_dir" echo "" # Get latest stable version for revert instructions local stable_version="X.Y.Z" local package_lower=$(echo "$PACKAGE_NAME" | tr '[:upper:]' '[:lower:]') if command -v curl >/dev/null 2>&1; then local nuget_response=$(curl -s "https://api.nuget.org/v3-flatcontainer/$package_lower/index.json" 2>/dev/null || echo "") if [[ -n "$nuget_response" ]]; then # Extract stable versions (those without -) stable_version=$(echo "$nuget_response" | grep -o '"[0-9]\+\.[0-9]\+\.[0-9]\+"' | grep -v '-' | tail -1 | tr -d '"') if [[ -z "$stable_version" ]]; then stable_version="X.Y.Z" fi fi fi echo -e "${YELLOW}═══════════════════════════════════════════════════════════${NC}" echo -e "${YELLOW} TO REVERT TO PRODUCTION VERSION${NC}" echo -e "${YELLOW}═══════════════════════════════════════════════════════════${NC}" echo "" echo -e "${WHITE}1. Edit $project_name and change the version:${NC}" echo -e "${GRAY} From: Version=\"$version\"${NC}" echo -e "${GRAY} To: Version=\"$stable_version\"${NC}" echo -e "${DGRAY} (Check https://www.nuget.org/packages/$PACKAGE_NAME for latest)${NC}" echo "" echo -e "${WHITE}2. In NuGet.config, remove or comment out the 'maui-pr-$pr_number' source${NC}" echo "" echo -e "${WHITE}3. Run: dotnet restore --force${NC}" echo "" echo -e "${CYAN}TIP: Use a separate Git branch for testing PR builds!${NC}" echo -e "${CYAN} Then you can easily revert: git checkout main${NC}" echo "" } # Run main function main "$@"